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; }