import { sub } from 'date-fns'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const'; import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; import { QueryResults } from '../../view/query-results'; import type { UnifiedQuery } from '../../view/unified-query'; import { UnifiedQueryBuilder } from '../../view/unified-query-builder'; import { UnifiedQueryRunner } from '../../view/unified-query-runner'; import type { View } from '../../view/view'; import { doesViewRequireCamera, isViewSupported } from '../../view/view-support'; import type { CardViewAPI } from '../types'; import { MergeContextViewModifier } from './modifiers/merge-context'; import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property'; import { SetQueryViewModifier } from './modifiers/set-query'; import type { QueryExecutorOptions, ViewModifier } from './types'; /** * This class executes media queries and returns an array of ViewModifiers that * can be applied to a view. This allows a view to be set when the user acts, * and if a query is made as part of this view the result can be applied later. */ export class ViewQueryExecutor { private _api: CardViewAPI; constructor(api: CardViewAPI) { this._api = api; } public async getExistingQueryModifiers( view: View, queryExecutorOptions?: QueryExecutorOptions, ): Promise { if (!view.query) { return null; } const runner = new UnifiedQueryRunner( this._api.getCameraManager(), this._api.getFoldersManager(), this._api.getConditionStateManager(), ); const items = await runner.execute(view.query, { useCache: queryExecutorOptions?.useCache, }); const queryResults = this._applyResultSelection( new QueryResults({ results: items }), queryExecutorOptions, ); return queryResults ? [ new SetQueryViewModifier({ queryResults, }), ] : null; } public async getNewQueryModifiers( view: View, queryExecutorOptions?: QueryExecutorOptions, ): Promise { return await this._executeNewQuery(view, { useCache: false, ...queryExecutorOptions, }); } private async _executeNewQuery( view: View, queryExecutorOptions?: QueryExecutorOptions, ): Promise { const config = this._api.getConfigManager().getConfig(); if (!config) { return null; } const viewModifiers: ViewModifier[] = []; const builder = new UnifiedQueryBuilder( this._api.getCameraManager(), this._api.getFoldersManager(), ); const runner = new UnifiedQueryRunner( this._api.getCameraManager(), this._api.getFoldersManager(), this._api.getConditionStateManager(), ); const executeQuery = async (query: UnifiedQuery | null): Promise => { if (!query) { return []; } const items = await runner.execute(query, { useCache: queryExecutorOptions?.useCache, }); return [ new SetQueryViewModifier({ query, queryResults: new QueryResults({ results: items }), }), ]; }; // Don't query if the view is not supported (e.g. a camera-based view with // no cameras). if ( !isViewSupported( view.view, this._api.getCameraManager(), this._api.getFoldersManager(), view.camera, ) ) { return null; } // Don't query if the view requires a camera, if it's not in grid mode, but // no camera is provided. if (!view.isGrid() && !view.camera && doesViewRequireCamera(view.view)) { return null; } const cameraForQuery = view.isGrid() ? undefined : view.camera ?? undefined; const getDefaultQueryModifiers = async () => { const query = builder.buildDefaultCameraQuery(cameraForQuery, { limit: this._getLimit(), }); return await executeQuery(query); }; switch (view.view) { case 'live': if (config.live.controls.thumbnails.mode !== 'none') { viewModifiers.push(...(await getDefaultQueryModifiers())); } break; case 'timeline': // Timeline view always queries all cameras with media capabilities. viewModifiers.push(...(await executeQuery(builder.buildDefaultCameraQuery()))); break; case 'gallery': case 'media': // If the user is looking at media in the `media` view and then changes // camera (via the menu) it should default to the default camera media // type. viewModifiers.push(...(await getDefaultQueryModifiers())); break; case 'clip': case 'clips': viewModifiers.push( ...(await executeQuery( builder.buildCameraMediaQuery('events', { cameraID: cameraForQuery, eventsSubtype: 'clips', limit: this._getLimit(), }), )), ); break; case 'snapshot': case 'snapshots': viewModifiers.push( ...(await executeQuery( builder.buildCameraMediaQuery('events', { cameraID: cameraForQuery, eventsSubtype: 'snapshots', limit: this._getLimit(), }), )), ); break; case 'recording': case 'recordings': viewModifiers.push( ...(await executeQuery( builder.buildCameraMediaQuery('recordings', { cameraID: cameraForQuery, limit: this._getLimit(), }), )), ); break; case 'review': case 'reviews': viewModifiers.push( ...(await executeQuery( builder.buildCameraMediaQuery('reviews', { cameraID: cameraForQuery, limit: this._getLimit(), }), )), ); break; case 'folder': case 'folders': viewModifiers.push( ...(await executeQuery( builder.buildDefaultFolderQuery(queryExecutorOptions?.folder, { limit: this._getLimit(), }), )), ); break; } viewModifiers.push(...this._getTimelineWindowViewModifier(view)); viewModifiers.push( ...this._getSeekTimeModifier(queryExecutorOptions?.selectResult?.time?.time), ); return viewModifiers; } private _getTimelineWindowViewModifier(view: View): ViewModifier[] { if (view.is('live')) { // For live views, always force the timeline to now, regardless of // presence or not of events. const now = new Date(); const liveConfig = this._api.getConfigManager().getConfig()?.live; /* v8 ignore if: this if branch cannot be reached as if the config is empty this function is never called -- @preserve */ if (!liveConfig) { return []; } return [ new MergeContextViewModifier({ // Force the window to start at the most recent time, not // necessarily when the most recent event/recording was: // https://github.com/dermotduffy/advanced-camera-card/issues/1301 timeline: { window: { start: sub(now, { seconds: liveConfig.controls.timeline.window_seconds, }), end: now, }, }, }), ]; } else { // For non-live views stick to default timeline behavior (will select and // scroll to event). return [new RemoveContextPropertyViewModifier('timeline', 'window')]; } } private _getSeekTimeModifier(time?: Date): ViewModifier[] { if (time) { return [ new MergeContextViewModifier({ mediaViewer: { seek: time, }, }), ]; } else { return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')]; } } private _applyResultSelection( queryResults: QueryResults, options?: QueryExecutorOptions, ): QueryResults | null { if (options?.rejectResults?.(queryResults)) { return null; } const timeSelection = options?.selectResult?.time; if (options?.selectResult?.id) { queryResults.selectBestResult((media) => media.findIndex((m) => m.getID() === options.selectResult?.id), ); } else if (options?.selectResult?.func) { queryResults.selectResultIfFound(options.selectResult.func); } else if (timeSelection) { queryResults.selectBestResult((itemArray) => findBestMediaTimeIndex( itemArray, timeSelection.time, timeSelection.favorCameraID, ), ); } return queryResults; } private _getLimit(): number { return ( this._api.getConfigManager().getConfig()?.performance?.features ?.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT ); } }