From fb65c3f99e07525db6a18960215a974d4a9ebf9c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 2 Oct 2022 14:41:00 -0700 Subject: [PATCH 1/4] Initial version of recordings button. --- src/card.ts | 26 +++ src/components/gallery.ts | 58 ++++-- src/components/timeline-core.ts | 320 ++++++-------------------------- src/components/viewer.ts | 38 +++- src/editor.ts | 5 +- src/localize/languages/en.json | 6 +- src/types.ts | 7 +- src/utils/ha/browse-media.ts | 5 +- src/utils/media-to-view.ts | 311 +++++++++++++++++++++++++++++++ src/view.ts | 18 +- 10 files changed, 495 insertions(+), 299 deletions(-) create mode 100644 src/utils/media-to-view.ts diff --git a/src/card.ts b/src/card.ts index 94368bae..96b07dce 100644 --- a/src/card.ts +++ b/src/card.ts @@ -467,6 +467,29 @@ export class FrigateCard extends LitElement { }); } + // Don't show `recordings` button if there's no `camera_name` (e.g. non-Frigate + // cameras), or is birdseye (unless there are dependent cameras). + if ( + cameraConfig?.frigate.camera_name && + (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || + cameraConfig?.dependencies.cameras.length || + cameraConfig?.dependencies.all_cameras) + ) { + buttons.push({ + icon: 'mdi:album', + ...this._getConfig().menu.buttons.recordings, + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.recordings'), + style: this._view?.is('recordings') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction( + 'recordings', + ) as FrigateCardCustomAction, + hold_action: createFrigateCardCustomAction( + 'recording', + ) as FrigateCardCustomAction, + }); + } + buttons.push({ icon: 'mdi:image', ...this._getConfig().menu.buttons.image, @@ -1369,6 +1392,8 @@ export class FrigateCard extends LitElement { case 'clips': case 'image': case 'live': + case 'recording': + case 'recordings': case 'snapshot': case 'snapshots': case 'timeline': @@ -1931,6 +1956,7 @@ export class FrigateCard extends LitElement { .view=${this._view} .cameras=${this._cameras} .galleryConfig=${this._getConfig().event_gallery} + .dataManager=${this._timelineDataManager} > ` : ``} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index d0391272..a3cc3c93 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { css, CSSResultGroup, @@ -23,6 +22,10 @@ import { fetchLatestMediaAndDispatchViewChange, getFullDependentBrowseMediaQueryParametersOrDispatchError, } from '../utils/ha/browse-media'; +import { + changeViewToRecentRecording, +} from '../utils/media-to-view.js'; +import { TimelineDataManager } from '../utils/timeline-data-manager'; import { View } from '../view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; @@ -42,6 +45,9 @@ export class FrigateCardGallery extends LitElement { @property({ attribute: false }) public cameras?: Map; + @property({ attribute: false }) + public dataManager?: TimelineDataManager; + /** * Master render method. * @returns A rendered template. @@ -53,31 +59,45 @@ export class FrigateCardGallery extends LitElement { !this.view || !this.cameras || !this.view.isGalleryView() || - !mediaType + !mediaType || + !this.dataManager ) { return; } if (!this.view.target) { - const browseMediaQueryParameters = - getFullDependentBrowseMediaQueryParametersOrDispatchError( + if (mediaType === 'recordings') { + changeViewToRecentRecording( this, this.hass, + this.dataManager, this.cameras, - this.view.camera, - mediaType, + this.view, + { + targetView: 'recordings', + }, ); + } else { + const browseMediaQueryParameters = + getFullDependentBrowseMediaQueryParametersOrDispatchError( + this, + this.hass, + this.cameras, + this.view.camera, + mediaType, + ); - if (!browseMediaQueryParameters) { - return; + if (!browseMediaQueryParameters) { + return; + } + + fetchLatestMediaAndDispatchViewChange( + this, + this.hass, + this.view, + browseMediaQueryParameters, + ); } - - fetchLatestMediaAndDispatchViewChange( - this, - this.hass, - this.view, - browseMediaQueryParameters, - ); return renderProgressIndicator(); } @@ -211,7 +231,7 @@ export class FrigateCardGalleryCore extends LitElement { !this.view || !this.view.target || !this.view.target.children || - !(this.view.is('clips') || this.view.is('snapshots')) || + !this.view.isGalleryView() || !this.cameras ) { return html``; @@ -254,8 +274,7 @@ export class FrigateCardGalleryCore extends LitElement {
${child.title}
` - : child.thumbnail - ? html` - ` - : ``} + `} `, )} `; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 3d76e00e..8cf0e48d 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -3,7 +3,6 @@ import { differenceInSeconds, endOfHour, fromUnixTime, - getUnixTime, startOfHour, sub, } from 'date-fns'; @@ -46,23 +45,23 @@ 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 { + changeViewToRecording, + findChildIndex, + generateMediaViewerContextForChildren, +} from '../utils/media-to-view'; import { FrigateCardTimelineItem, - RecordingSegmentsItem, - sortSegmentsOldestToYoungest, sortTimelineItemsYoungestToOldest, TimelineDataManager, } from '../utils/timeline-data-manager'; @@ -293,164 +292,6 @@ export class FrigateCardTimelineCore extends LitElement { 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 @@ -540,13 +381,19 @@ export class FrigateCardTimelineCore extends LitElement { targetTime: Date, properties: TimelineRangeChange, ): void { - if (!this._timeline || !this.view || !this.view.target?.children?.length) { + if ( + !this._timeline || + !this.view || + !this.view.target?.children?.length || + !this.timelineDataManager + ) { return; } const canSeek = !!this.view?.isViewerView(); const context = canSeek - ? this._generateMediaViewerContextForChildren( + ? generateMediaViewerContextForChildren( + this.timelineDataManager, this.view.target.children, targetTime, ) @@ -554,7 +401,7 @@ export class FrigateCardTimelineCore extends LitElement { const childIndex = this._locked ? null - : this._findChildIndex( + : findChildIndex( this.view.target.children, targetTime, this._getTimelineCameraIDs(), @@ -576,90 +423,6 @@ export class FrigateCardTimelineCore extends LitElement { } } - /** - * 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. @@ -672,34 +435,68 @@ export class FrigateCardTimelineCore extends LitElement { stopEventFromActivatingCardWideActions(properties.event); } - if (!this._ignoreClick && properties.what) { + if ( + !this._ignoreClick && + properties.what && + this.hass && + this.timelineDataManager && + this.cameras && + this.view + ) { if ( this.timelineConfig?.show_recordings && ['background', 'group-label', 'axis'].includes(properties.what) ) { + stopEventFromActivatingCardWideActions(properties.event); + 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), + changeViewToRecording( + this, + this.hass, + this.timelineDataManager, + this.cameras, + this.view, { + cameraIDs: new Set([String(properties.group)]), + targetTime: + properties.what === 'background' ? properties.time : window.end, + }, ); } 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); + changeViewToRecording( + this, + this.hass, + this.timelineDataManager, + this.cameras, + this.view, { + targetTime: window.end, + }, + ); } } } else { - stopEventFromActivatingCardWideActions(properties.event); - this._changeViewToRecording(properties.time); + changeViewToRecording( + this, + this.hass, + this.timelineDataManager, + this.cameras, + this.view, { + cameraIDs: this._getAllCameraIDs(), + start: startOfHour(properties.time), + end: endOfHour(properties.time), + targetTime: properties.time, + } + ); } } else if ( properties.what === 'item' && properties.item && this.view && - this.view.target?.children + this.view.target?.children && + this.timelineDataManager ) { let childIndex: number | null = null; let target: FrigateBrowseMediaSource | null = null; @@ -712,7 +509,8 @@ export class FrigateCardTimelineCore extends LitElement { target = thumbnails.target; childIndex = thumbnails.childIndex; if (thumbnails.target?.children?.length) { - context = this._generateMediaViewerContextForChildren( + context = generateMediaViewerContextForChildren( + this.timelineDataManager, thumbnails.target.children, properties.time, ); diff --git a/src/components/viewer.ts b/src/components/viewer.ts index e1159360..a2f73302 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -56,6 +56,7 @@ 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'; +import { changeViewToRecentRecording } from '../utils/media-to-view.js'; export interface MediaSeek { // Specifies the point at which this recording should be played, the @@ -101,7 +102,13 @@ export class FrigateCardViewer extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) { + if ( + !this.hass || + !this.view || + !this.cameras || + !this.viewerConfig || + !this.timelineDataManager + ) { return; } @@ -123,14 +130,27 @@ export class FrigateCardViewer extends LitElement { return; } - fetchLatestMediaAndDispatchViewChange( - this, - this.hass, - this.view, - overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, { - mediaType: mediaType, - }), - ); + if (mediaType === 'recordings') { + changeViewToRecentRecording( + this, + this.hass, + this.timelineDataManager, + this.cameras, + this.view, + { + targetView: 'recording', + }, + ); + } else { + fetchLatestMediaAndDispatchViewChange( + this, + this.hass, + this.view, + overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, { + mediaType: mediaType, + }), + ); + } return renderProgressIndicator(); } diff --git a/src/editor.ts b/src/editor.ts index 76b45c52..6d6b58f5 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -248,8 +248,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'live', label: localize('config.view.views.live') }, { value: 'clips', label: localize('config.view.views.clips') }, { value: 'snapshots', label: localize('config.view.views.snapshots') }, + { value: 'recordings', label: localize('config.view.views.recordings') }, { value: 'clip', label: localize('config.view.views.clip') }, { value: 'snapshot', label: localize('config.view.views.snapshot') }, + { value: 'recording', label: localize('config.view.views.recording') }, { value: 'image', label: localize('config.view.views.image') }, { value: 'timeline', label: localize('config.view.views.timeline') }, ]; @@ -936,7 +938,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor * @param configPathShowDetails Thumbnails show details config path. * @param configPathShowFavoriteControl Thumbnails show favorite control config path. * @param configPathShowTimelineControl Thumbnails show timeline control config path, - * @param configPathMedia Thumbnails media config path. + * @param options An optional config path to media selection and mini-timeline mode. * @returns A rendered template. */ protected _renderThumbnailsControls( @@ -1437,6 +1439,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderMenuButton('live') /* */} ${this._renderMenuButton('clips') /* */} ${this._renderMenuButton('snapshots')} + ${this._renderMenuButton('recordings')} ${this._renderMenuButton('image') /* */} ${this._renderMenuButton('download')} ${this._renderMenuButton('frigate_ui')} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 4346d7b8..7c2f0b57 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -7,6 +7,7 @@ "no_clips": "No clips", "no_snapshot": "No recent snapshot", "no_snapshots": "No snapshots", + "no_recording": "No recent recording", "recordings": "Recordings", "version": "Version" }, @@ -215,7 +216,8 @@ "media_player": "Send to media player", "priority": "Priority", "snapshots": "Snapshots", - "timeline": "Timeline" + "timeline": "Timeline", + "recordings": "Recordings" }, "position": "Menu position", "positions": { @@ -262,6 +264,8 @@ "current": "Current view", "image": "Static image", "live": "Live view", + "recording": "Most recent recording", + "recordings": "Recordings gallery", "snapshot": "Most recent snapshot", "snapshots": "Snapshots gallery", "timeline": "Timeline view" diff --git a/src/types.ts b/src/types.ts index 8141c19d..f6f51c37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,13 +34,14 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ 'clips', 'snapshot', 'snapshots', + 'recording', + 'recordings', 'image', 'timeline', ] as const; const FRIGATE_CARD_VIEWS = [ ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, - 'recording', // Media: A generic piece of media (could be clip, snapshot, recording). 'media', @@ -204,6 +205,8 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'fullscreen', 'menu_toggle', 'diagnostics', + 'recording', + 'recordings' ] as const; const FRIGATE_CARD_ACTIONS = [ ...FRIGATE_CARD_GENERAL_ACTIONS, @@ -907,6 +910,7 @@ const menuConfigDefault = { frigate_ui: visibleButtonDefault, fullscreen: visibleButtonDefault, media_player: visibleButtonDefault, + recordings: hiddenButtonDefault, }, button_size: 40, }; @@ -940,6 +944,7 @@ const menuConfigSchema = z media_player: visibleButtonSchema.default( menuConfigDefault.buttons.media_player, ), + recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 46086c2c..02cb574b 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -9,6 +9,7 @@ import { localize } from '../../localize/localize.js'; import { BrowseMediaQueryParameters, CameraConfig, + ClipsOrSnapshots, FrigateBrowseMediaSource, frigateBrowseMediaSourceSchema, FrigateCardError, @@ -258,7 +259,7 @@ export const getFullDependentBrowseMediaQueryParameters = ( hass: HomeAssistant, cameras: Map, camera: string, - mediaType?: 'clips' | 'snapshots', + mediaType?: ClipsOrSnapshots, ): BrowseMediaQueryParameters[] | null => { const cameraIDs = getAllDependentCameras(cameras, camera); const params: BrowseMediaQueryParameters[] = []; @@ -290,7 +291,7 @@ export const getFullDependentBrowseMediaQueryParametersOrDispatchError = ( hass: HomeAssistant, cameras: Map, camera: string, - mediaType?: 'clips' | 'snapshots', + mediaType?: ClipsOrSnapshots, ): BrowseMediaQueryParameters[] | null => { const params = getFullDependentBrowseMediaQueryParameters( hass, diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts new file mode 100644 index 00000000..41ee43ae --- /dev/null +++ b/src/utils/media-to-view.ts @@ -0,0 +1,311 @@ +import { add, endOfHour, fromUnixTime, getUnixTime, startOfHour, sub } from 'date-fns'; +import { ViewContext } from 'view'; +import { dispatchMessageEvent } from '../components/message'; +import { localize } from '../localize/localize'; +import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types'; +import { View } from '../view'; +import { formatDateAndTime, prettifyTitle } from './basic'; +import { getRecordingMediaContentID } from './frigate'; +import { createChild, createEventParentForChildren } from './ha/browse-media'; +import { + RecordingSegmentsItem, + sortSegmentsOldestToYoungest, + TimelineDataManager, +} from './timeline-data-manager'; + +/** + * Change the view to a recent recording. + * @param element The element to dispatch the view change from. + * @param hass The Home Assistant object. + * @param dataManager The datamanager to use for data access. + * @param cameras The camera configurations. + * @param view The current view. + * @param cameraIDs The camera IDs to include recordings for. + * @param options A specific window (start and end) to fetch recordings for, and a targetTime to seek to. + */ + export const changeViewToRecentRecording = async ( + element: HTMLElement, + hass: ExtendedHomeAssistant, + dataManager: TimelineDataManager, + cameras: Map, + view: View, + options?: { + cameraIDs?: Set; + targetView?: 'recording' | 'recordings'; + }, +): Promise => { + const now = new Date(); + + await changeViewToRecording( + element, + hass, + dataManager, + cameras, + view, + { + ...options, + + // Fetch 1 days worth of recordings (including recordings that are for the current hour). + start: sub(now, { days: 1 }), + end: add(now, { hours: 1 }), + } + ); +} + +/** + * Change the view to a recording. + * @param element The element to dispatch the view change from. + * @param hass The Home Assistant object. + * @param dataManager The datamanager to use for data access. + * @param cameras The camera configurations. + * @param view The current view. + * @param cameraIDs The camera IDs to include recordings for. + * @param options A specific window (start and end) to fetch recordings for, and a targetTime to seek to. + */ +export const changeViewToRecording = async ( + element: HTMLElement, + hass: ExtendedHomeAssistant, + dataManager: TimelineDataManager, + cameras: Map, + view: View, + options?: { + cameraIDs?: Set; + targetView?: 'recording' | 'recordings'; + targetTime?: Date; + start?: Date; + end?: Date; + }, +): Promise => { + if (options && options.start && options.end) { + await dataManager.fetchIfNecessary(element, hass, options.start, options.end); + } + + const cameraIDs: Set = options?.cameraIDs + ? options.cameraIDs + : new Set([view.camera]); + const children = createRecordingChildren(dataManager, cameras, cameraIDs, { + ...(options?.start && options?.end && { start: options.start, end: options.end }), + }); + + if (!children.length) { + return dispatchMessageEvent(element, localize('common.no_recording'), 'info', { + icon: 'mdi:album', + }); + } + + const viewerContext = options?.targetTime + ? generateMediaViewerContextForChildren(dataManager, children, options.targetTime) + : {}; + const childIndex = options?.targetTime + ? findChildIndex(children, options.targetTime, cameraIDs) + : null; + const child = childIndex !== null ? children[childIndex] ?? null : null; + + view + ?.evolve({ + view: options?.targetView ? options.targetView : 'recording', + target: createEventParentForChildren(localize('common.recordings'), children), + childIndex: childIndex ?? 0, + ...(child?.frigate?.cameraID && { camera: child.frigate?.cameraID }), + }) + .mergeInContext(viewerContext) + .dispatchChangeEvent(element); +}; + +/** + * Create recording objects. + * @param dataManager The datamanager to use for data access. + * @param cameras The camera configurations. + * @param cameraIDs The camera IDs to include recordings for. + * @param options A specific window (start and end) to allow recordings for. + * @returns + */ +const createRecordingChildren = ( + dataManager: TimelineDataManager, + cameras: Map, + cameraIDs: Set, + options?: { + start?: Date; + end?: Date; + }, +): FrigateBrowseMediaSource[] => { + const children: FrigateBrowseMediaSource[] = []; + + for (const cameraID of cameraIDs) { + const config = cameras.get(cameraID); + const recordingSummary = dataManager.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); + + if ( + (!options?.start || startHour >= options.start) && + (!options?.end || endHour <= options.end) + ) { + 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; +}; + +/** + * Generate the media view context for a set of media children (used to set + * seek times into each media item). + * @param dataManager The datamanager to use for data access. + * @param children The media children. + * @param targetTime The target time. + * @returns The ViewContext. + */ +export const generateMediaViewerContextForChildren = ( + dataManager: TimelineDataManager, + children: FrigateBrowseMediaSource[], + targetTime: Date, +): ViewContext => { + const seek = new Map(); + const segmentsDataset = dataManager.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 = 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 } } : {}; +}; + +/** + * 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. + */ +export const 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; +}; + +/** + * 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 + */ +const 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; +}; diff --git a/src/view.ts b/src/view.ts index dca38507..922b19e0 100644 --- a/src/view.ts +++ b/src/view.ts @@ -147,7 +147,7 @@ export class View { * Determine if a view is a gallery. */ public isGalleryView(): boolean { - return this.view == 'clips' || this.view == 'snapshots'; + return ['clips', 'snapshots', 'recordings'].includes(this.view); } /** @@ -180,14 +180,24 @@ export class View { } /** - * Get the media type for this view if available. - * @returns Whether the media is `clips` or `snapshots` or unknown (`null`) + * Determine if a view is related to a recording or recordings. */ - public getMediaType(): 'clips' | 'snapshots' | null { + public isRecordingRelatedView(): boolean { + return ['recording', 'recordings'].includes(this.view); + } + + /** + * Get the media type for this view if available. + * @returns Whether the media is `clips`, `snapshots`, `recordings` or unknown + * (`null`). + */ + public getMediaType(): 'clips' | 'snapshots' | 'recordings' | null { return this.isClipRelatedView() ? 'clips' : this.isSnapshotRelatedView() ? 'snapshots' + : this.isRecordingRelatedView() + ? 'recordings' : null; } From a5dfe81eab067e543c82977b17bc3474d80b0abf Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 2 Oct 2022 18:14:37 -0700 Subject: [PATCH 2/4] Fixes for record button. --- src/components/gallery.ts | 26 ++++++++++++----------- src/components/thumbnail-carousel.ts | 2 +- src/components/thumbnail.ts | 22 +++++++++++++------ src/components/timeline-core.ts | 12 ++++++++++- src/components/viewer.ts | 4 ++-- src/scss/thumbnail-feature-recording.scss | 4 +++- src/utils/media-to-view.ts | 13 ++++++------ src/view.ts | 14 ++++++++++++ 8 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/components/gallery.ts b/src/components/gallery.ts index a3cc3c93..30ce89c3 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -22,9 +22,7 @@ import { fetchLatestMediaAndDispatchViewChange, getFullDependentBrowseMediaQueryParametersOrDispatchError, } from '../utils/ha/browse-media'; -import { - changeViewToRecentRecording, -} from '../utils/media-to-view.js'; +import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; import { TimelineDataManager } from '../utils/timeline-data-manager'; import { View } from '../view.js'; import { renderProgressIndicator } from './message.js'; @@ -67,7 +65,7 @@ export class FrigateCardGallery extends LitElement { if (!this.view.target) { if (mediaType === 'recordings') { - changeViewToRecentRecording( + changeViewToRecentRecordingForCameraAndDependents( this, this.hass, this.dataManager, @@ -237,7 +235,6 @@ export class FrigateCardGalleryCore extends LitElement { return html``; } - const cameraConfig = this.cameras.get(this.view.camera); return html` ${this._showBackArrow() ? html` { if (this.view) { - this.view - .evolve({ - view: this.view.is('clips') ? 'clip' : 'snapshot', - childIndex: index, - }) - .dispatchChangeEvent(this); + const targetView = this.view.getViewerViewForGalleryView(); + if (targetView) { + this.view + .evolve({ + view: targetView, + childIndex: index, + }) + .dispatchChangeEvent(this); + } } stopEventFromActivatingCardWideActions(ev); }} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 6cdeea48..1dfc3f39 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -208,7 +208,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { .target=${parent} .childIndex=${childIndex} .mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)} - .clientID=${cameraConfig?.frigate.client_id} + .cameraConfig=${cameraConfig ?? undefined} ?details=${this.config?.show_details} ?show_favorite_control=${this.config?.show_favorite_control} ?show_timeline_control=${this.config?.show_timeline_control} diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 8468515d..e43b30d6 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -10,6 +10,7 @@ import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording. import thumbnailStyle from '../scss/thumbnail.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js'; +import { getCameraTitle } from '../utils/camera.js'; import { retainEvent } from '../utils/frigate.js'; import { getEventDurationString } from '../utils/frigate.js'; import { renderTask } from '../utils/task.js'; @@ -19,6 +20,7 @@ import { MediaSeek } from './viewer.js'; import { TaskStatus } from '@lit-labs/task'; import type { + CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, FrigateEvent, @@ -108,6 +110,9 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @property({ attribute: false }) public date?: Date; + @property({ attribute: false }) + public cameraTitle?: string; + protected render(): TemplateResult | void { if (!this.date) { return; @@ -115,6 +120,7 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { return html`
${format(this.date, 'HH:mm')}
${format(this.date, 'MMM do')}
+ ${this.cameraTitle ? html`
${this.cameraTitle}
` : html``} `; } @@ -240,7 +246,7 @@ export class FrigateCardThumbnail extends LitElement { public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - public clientID?: string; + public cameraConfig?: CameraConfig; /** * Render the element. @@ -277,6 +283,7 @@ export class FrigateCardThumbnail extends LitElement { starred: !!event?.retain_indefinitely, }; + const clientID = this.cameraConfig?.frigate.client_id; return html` ${event ? html`` - : html``} - ${this.show_favorite_control && event && this.hass && this.clientID + >` + : html``} + ${this.show_favorite_control && event && this.hass && clientID ? html` { stopEventFromActivatingCardWideActions(ev); - if (event && this.hass && this.clientID) { + if (event && this.hass && clientID) { retainEvent( this.hass, - this.clientID, + clientID, event.id, !event.retain_indefinitely, ) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 8cf0e48d..ebb1d37b 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1024,6 +1024,7 @@ export class FrigateCardTimelineCore extends LitElement { } const options = this._getOptions(); + let createdTimeline = false; if ( this.timelineDataManager && @@ -1056,6 +1057,7 @@ export class FrigateCardTimelineCore extends LitElement { this.timelineConfig.media, ); + createdTimeline = true; if (this.mini && groups.length === 1) { // In a mini timeline, if there's only one group don't bother grouping // at all. @@ -1097,7 +1099,15 @@ export class FrigateCardTimelineCore extends LitElement { } if (changedProperties.has('view')) { - this._updateTimelineFromView(); + if (createdTimeline) { + // If the timeline was just created, give it one frame to draw itself. + // Failure to do so may result in subsequent calls to + // `this._timeline.setwindow()` being entirely ignored. Example case: + // Clicking the timeline control on a recording thumbnail. + window.requestAnimationFrame(this._updateTimelineFromView.bind(this)); + } else { + this._updateTimelineFromView(); + } } } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index a2f73302..465c8241 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -56,7 +56,7 @@ 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'; -import { changeViewToRecentRecording } from '../utils/media-to-view.js'; +import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js'; export interface MediaSeek { // Specifies the point at which this recording should be played, the @@ -131,7 +131,7 @@ export class FrigateCardViewer extends LitElement { } if (mediaType === 'recordings') { - changeViewToRecentRecording( + changeViewToRecentRecordingForCameraAndDependents( this, this.hass, this.timelineDataManager, diff --git a/src/scss/thumbnail-feature-recording.scss b/src/scss/thumbnail-feature-recording.scss index 62907313..dab70503 100644 --- a/src/scss/thumbnail-feature-recording.scss +++ b/src/scss/thumbnail-feature-recording.scss @@ -21,7 +21,9 @@ div { text-align: center; } - div.title { font-size: 1.5rem; } +div.camera { + font-size: 0.7em; +} diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 41ee43ae..3874872b 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -12,6 +12,7 @@ import { sortSegmentsOldestToYoungest, TimelineDataManager, } from './timeline-data-manager'; +import { getAllDependentCameras } from './camera.js'; /** * Change the view to a recent recording. @@ -20,17 +21,15 @@ import { * @param dataManager The datamanager to use for data access. * @param cameras The camera configurations. * @param view The current view. - * @param cameraIDs The camera IDs to include recordings for. - * @param options A specific window (start and end) to fetch recordings for, and a targetTime to seek to. + * @param options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to. */ - export const changeViewToRecentRecording = async ( + export const changeViewToRecentRecordingForCameraAndDependents = async ( element: HTMLElement, hass: ExtendedHomeAssistant, dataManager: TimelineDataManager, cameras: Map, view: View, options?: { - cameraIDs?: Set; targetView?: 'recording' | 'recordings'; }, ): Promise => { @@ -46,6 +45,7 @@ import { ...options, // Fetch 1 days worth of recordings (including recordings that are for the current hour). + cameraIDs: getAllDependentCameras(cameras, view.camera), start: sub(now, { days: 1 }), end: add(now, { hours: 1 }), } @@ -59,8 +59,9 @@ import { * @param dataManager The datamanager to use for data access. * @param cameras The camera configurations. * @param view The current view. - * @param cameraIDs The camera IDs to include recordings for. - * @param options A specific window (start and end) to fetch recordings for, and a targetTime to seek to. + * @param options A specific window (start and end) to fetch recordings for, a + * targetTime to seek to, a targetView to dispatch to and a set of cameraIDs to + * restrict to. */ export const changeViewToRecording = async ( element: HTMLElement, diff --git a/src/view.ts b/src/view.ts index 922b19e0..3d82ce5d 100644 --- a/src/view.ts +++ b/src/view.ts @@ -150,6 +150,20 @@ export class View { return ['clips', 'snapshots', 'recordings'].includes(this.view); } + /** + * Get the viewer view given a gallery view. + */ + public getViewerViewForGalleryView(): 'clip' | 'snapshot' | 'recording' | null { + if (this.is('clips')) { + return 'clip'; + } else if (this.is('snapshots')) { + return 'snapshot'; + } else if (this.is('recordings')) { + return 'recording'; + } + return null; + } + /** * Determine if a view is of a piece of media (including the media viewer, * live view, image view -- anything that can create a MediaLoadedInfo event). From 33e0c0da4016c1319a0f856d33515b573110d942 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 2 Oct 2022 18:21:10 -0700 Subject: [PATCH 3/4] README for new recordings button. --- README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5b74d51c..8799abd0 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,7 @@ menu: | `live` | :white_check_mark: | The `live` view menu button: brings the user to the `live` view. See [views](#views) below.| | `clips` | :white_check_mark: | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.| | `snapshots` | :white_check_mark: | The `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.| +| `recordings` | :white_check_mark: | The `recordings` view menu button: brings the user to the `recordings` view on tap and the most-recent `recording` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.| | `image` | :white_check_mark: | The `image` view menu button: brings the user to the static `image` view. See [views](#views) below.| | `download` | :white_check_mark: | The `download` menu button: allow direct download of the media being displayed.| | `frigate_ui` | :white_check_mark: | The `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.| @@ -521,7 +522,7 @@ media_viewer: ### Event Gallery Options -The `event_gallery` is used for providing an overview of all `clips` and `snapshots` in a thumbnail gallery. +The `event_gallery` is used for providing an overview of all `clips`, `snapshots` and `recordings` in a thumbnail gallery. All configuration is under: @@ -537,7 +538,7 @@ See the [fully expanded event gallery configuration example](#config-expanded-ev | `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| | `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.| -| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`). See [actions](#actions) below.| +| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`, `recordings`). See [actions](#actions) below.| ### Image Options @@ -916,14 +917,14 @@ Parameters for the `custom:frigate-card-ptz` element: | Parameter | Description | | - | - | | `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `timeline`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`.| +| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`.| ##### Command descriptions | Value | Description | | - | - | | `default` | Trigger the default view. | -| `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots` | Trigger the named [view](#views).| +| `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots` | Trigger the named [view](#views).| |`download`|Download the displayed media.| |`frigate_ui`|Open the Frigate UI at the configured URL.| |`fullscreen`|Toggle fullscreen.| @@ -945,6 +946,8 @@ This card supports several different views: |`snapshot`|Shows a Media viewer for the most recent snapshot for this camera/zone/label. Can also be accessed by holding down the `snapshots` menu icon.| |`clips`|Shows an event gallery of clips for this camera/zone/label.| |`clip`|Shows a Media viewer for the most recent clip for this camera/zone/label. Can also be accessed by holding down the `clips` menu icon.| +|`recordings`|Shows a gallery of recordings for this camera.| +|`recording`|Shows a Media viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon.| |`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).| ### Navigating From A Snapshot To A Clip @@ -1005,8 +1008,8 @@ view. | Configuration path | Views to which it refers | | - | - | | `view.actions` | All (may be overriden by the below) | -| `media_viewer.actions` | `clip`, `snapshot` | -| `event_gallery.actions` | `clips`, `snapshots` | +| `media_viewer.actions` | `clip`, `snapshot`, `recording` | +| `event_gallery.actions` | `clips`, `snapshots`, `recordings` | | `live.actions` | `live` | | `image.actions` | `image` | @@ -2002,6 +2005,11 @@ overrides: enabled: true alignment: matching icon: mdi:camera + recordings: + priority: 50 + enabled: false + alignment: matching + icon: mdi:album image: priority: 50 # Disable the image button. From 72dca1b712bd9fe0cdd76e168c929213a3e0b044 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 2 Oct 2022 19:30:16 -0700 Subject: [PATCH 4/4] Dedup same cameras with different zones. --- README.md | 4 +-- src/components/timeline-core.ts | 4 +-- src/utils/camera.ts | 31 ++++++++++++++++++ src/utils/ha/browse-media.ts | 51 ++++++++++++++++++------------ src/utils/media-to-view.ts | 18 +++++++---- src/utils/timeline-data-manager.ts | 14 ++++---- 6 files changed, 83 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8799abd0..e233da0d 100644 --- a/README.md +++ b/README.md @@ -946,7 +946,7 @@ This card supports several different views: |`snapshot`|Shows a Media viewer for the most recent snapshot for this camera/zone/label. Can also be accessed by holding down the `snapshots` menu icon.| |`clips`|Shows an event gallery of clips for this camera/zone/label.| |`clip`|Shows a Media viewer for the most recent clip for this camera/zone/label. Can also be accessed by holding down the `clips` menu icon.| -|`recordings`|Shows a gallery of recordings for this camera.| +|`recordings`|Shows a gallery of recent (last day) recordings for this camera and its dependents.| |`recording`|Shows a Media viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon.| |`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).| @@ -2693,7 +2693,7 @@ menu: ### Using a dependent camera -`dependencies.cameras` allows events for other cameras to be shown along with the currently selected camera. For example, this can be used to show events with the `birdseye` camera (since it will not have events of its own). +`dependencies.cameras` allows events/recordings for other cameras to be shown along with the currently selected camera. For example, this can be used to show events with the `birdseye` camera (since it will not have events of its own).
Expand: Using dependent cameras with birdseye diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index ebb1d37b..104a2af4 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -62,7 +62,7 @@ import { } from '../utils/media-to-view'; import { FrigateCardTimelineItem, - sortTimelineItemsYoungestToOldest, + sortYoungestToOldest, TimelineDataManager, } from '../utils/timeline-data-manager'; import { View } from '../view'; @@ -619,7 +619,7 @@ export class FrigateCardTimelineCore extends LitElement { this._dataview ?.get({ filter: (item) => item.type !== 'background', - order: sortTimelineItemsYoungestToOldest, + order: sortYoungestToOldest, }) .forEach((item) => { const cameraID = item.group ? String(item.group) : null; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 8ef70904..3dd747e6 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -107,3 +107,34 @@ export const getAllDependentCameras = ( } return cameraIDs; }; + +/** + * Return the cameraIDs of truly unique cameras (some configured cameras may be + * the same Frigate came but with different zone/labels). + * @param cameras The full set of cameras. + * @param cameraIDs The specific IDs to dedup. + */ +export const getTrueCameras = ( + cameras: Map, + cameraIDs: Set, +): Set => { + const getTrueCameraID = (cameraConfig: CameraConfig): string => { + return `${cameraConfig.frigate?.client_id ?? ''}/${ + cameraConfig.frigate.camera_name ?? '' + }`; + }; + + const output = new Set(); + const visitedTrueCameras = new Set(); + cameraIDs.forEach((cameraID: string) => { + const cameraConfig = cameras.get(cameraID) ?? null; + if (cameraConfig && cameraConfig.frigate.camera_name) { + const trueCameraID = getTrueCameraID(cameraConfig); + if (!visitedTrueCameras.has(trueCameraID)) { + output.add(cameraID); + visitedTrueCameras.add(trueCameraID); + } + } + }); + return output; +}; diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 02cb574b..8fce396d 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -183,27 +183,7 @@ export const mergeFrigateBrowseMediaSources = async ( } } - 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 createEventParentForChildren('Merged events', children.sort(eventSort)); + return createEventParentForChildren('Merged events', children.sort(sortYoungestToOldest)); }; /** @@ -445,3 +425,32 @@ export const createChild = ( } return result; }; + +/** + * 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 sortYoungestToOldest = ( + a: FrigateBrowseMediaSource, + b: FrigateBrowseMediaSource, +): number => { + const a_source = a.frigate?.event ?? a.frigate?.recording; + const b_source = b.frigate?.event ?? b.frigate?.recording; + + if ( + !a_source || + (b_source && b_source.start_time > a_source.start_time) + ) { + return 1; + } + + if ( + !b_source || + (a_source && b_source.start_time < a_source.start_time) + ) { + return -1; + } + return 0; +}; \ No newline at end of file diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 3874872b..a248fb41 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -6,13 +6,13 @@ import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '. import { View } from '../view'; import { formatDateAndTime, prettifyTitle } from './basic'; import { getRecordingMediaContentID } from './frigate'; -import { createChild, createEventParentForChildren } from './ha/browse-media'; +import { createChild, createEventParentForChildren, sortYoungestToOldest } from './ha/browse-media'; import { RecordingSegmentsItem, - sortSegmentsOldestToYoungest, + sortOldestToYoungest, TimelineDataManager, } from './timeline-data-manager'; -import { getAllDependentCameras } from './camera.js'; +import { getAllDependentCameras, getTrueCameras } from './camera.js'; /** * Change the view to a recent recording. @@ -132,8 +132,10 @@ const createRecordingChildren = ( ): FrigateBrowseMediaSource[] => { const children: FrigateBrowseMediaSource[] = []; - for (const cameraID of cameraIDs) { - const config = cameras.get(cameraID); + for (const cameraID of getTrueCameras( + cameras, cameraIDs + )) { + const config = cameras.get(cameraID) ?? null; const recordingSummary = dataManager.getRecordingSummaryForCamera(cameraID); if (!config?.frigate.camera_name || !recordingSummary) { continue; @@ -175,7 +177,9 @@ const createRecordingChildren = ( } } } - return children; + // Sort the events by time (to align recordings for different cameras at the + // same time). + return children.sort(sortYoungestToOldest); }; /** @@ -208,7 +212,7 @@ export const generateMediaViewerContextForChildren = ( segment.cameraID === child.frigate?.cameraID && segment.start >= start && segment.end <= end, - order: sortSegmentsOldestToYoungest, + order: sortOldestToYoungest, }); seekSeconds = getSeekTimeInSegments( // Recordings start from the top of the hour. diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts index 680c351b..24a7016f 100644 --- a/src/utils/timeline-data-manager.ts +++ b/src/utils/timeline-data-manager.ts @@ -49,9 +49,9 @@ export interface RecordingSegmentsItem { * @param b The second item. * @returns -1, 0, 1 (standard array sort function configuration). */ -export const sortTimelineItemsYoungestToOldest = ( - a: FrigateCardTimelineItem, - b: FrigateCardTimelineItem, +export const sortYoungestToOldest = ( + a: RecordingSegmentsItem | FrigateCardTimelineItem, + b: RecordingSegmentsItem | FrigateCardTimelineItem, ): number => { if (a.start < b.start) { return 1; @@ -68,9 +68,9 @@ export const sortTimelineItemsYoungestToOldest = ( * @param b The second item. * @returns -1, 0, 1 (standard array sort function configuration). */ -export const sortSegmentsOldestToYoungest = ( - a: RecordingSegmentsItem, - b: RecordingSegmentsItem, +export const sortOldestToYoungest = ( + a: RecordingSegmentsItem | FrigateCardTimelineItem, + b: RecordingSegmentsItem | FrigateCardTimelineItem, ): number => { if (a.start < b.start) { return -1; @@ -438,7 +438,7 @@ export class TimelineDataManager { this._cameras.forEach((_, cameraID) => { const segments = this._recordingSegments.get({ filter: (item) => item.cameraID === cameraID, - order: sortSegmentsOldestToYoungest, + order: sortOldestToYoungest, }); let current: RecordingSegmentsItem | null = null; for (let i = 0; i < segments.length; ++i) {