diff --git a/docs/_coverpage.md b/docs/_coverpage.md index 7c0ab1ce..86de5a62 100644 --- a/docs/_coverpage.md +++ b/docs/_coverpage.md @@ -1,4 +1,4 @@ -![logo](images/icons/iris.svg ':size=48px') +Logo # Advanced Camera Card diff --git a/docs/index.html b/docs/index.html index bdfd5134..ff113d46 100644 --- a/docs/index.html +++ b/docs/index.html @@ -38,6 +38,19 @@ #__sidebar img { max-width: 48px; } + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(-360deg); + } + } + .app-name img, + .spin { + animation: spin 15s infinite linear; + transform-origin: center; + } diff --git a/rollup.config.js b/rollup.config.js index 08b86957..9a8b5a17 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -112,8 +112,8 @@ const config = { }, ], plugins: plugins, - // These files use this at the toplevel, which causes rollup warning - // spam on build: `this` has been rewritten to `undefined`. + // These files use `this` at the toplevel, which causes rollup warning spam on + // build: `this` has been rewritten to `undefined`. moduleContext: { './node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window', './node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window', diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 15af4d1f..60ba7fac 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -82,24 +82,24 @@ export class CameraQueryClassifier { export class QueryResultClassifier { public static isEventQueryResult( - queryResults: QueryResults, + queryResults?: QueryResults | null, ): queryResults is EventQueryResults { - return queryResults.type === QueryResultsType.Event; + return queryResults?.type === QueryResultsType.Event; } public static isRecordingQueryResult( - queryResults: QueryResults, + queryResults?: QueryResults | null, ): queryResults is RecordingQueryResults { - return queryResults.type === QueryResultsType.Recording; + return queryResults?.type === QueryResultsType.Recording; } public static isRecordingSegmentsQueryResult( - queryResults: QueryResults, + queryResults?: QueryResults | null, ): queryResults is RecordingSegmentsQueryResults { - return queryResults.type === QueryResultsType.RecordingSegments; + return queryResults?.type === QueryResultsType.RecordingSegments; } public static isMediaMetadataQueryResult( - queryResults: QueryResults, + queryResults?: QueryResults | null, ): queryResults is MediaMetadataQueryResults { - return queryResults.type === QueryResultsType.MediaMetadata; + return queryResults?.type === QueryResultsType.MediaMetadata; } } diff --git a/src/card-controller/actions/actions/folders-view.ts b/src/card-controller/actions/actions/folders-view.ts deleted file mode 100644 index 2da8310f..00000000 --- a/src/card-controller/actions/actions/folders-view.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { FoldersViewActionConfig } from '../../../config/schema/actions/custom/folders-view'; -import { FolderViewQuery } from '../../../view/query'; -import { CardActionsAPI } from '../../types'; -import { AdvancedCameraCardAction } from './base'; - -export class FoldersViewAction extends AdvancedCameraCardAction { - public async execute(api: CardActionsAPI): Promise { - await super.execute(api); - - const folder = api.getFoldersManager().getFolder(this._action.folder); - if (!folder) { - return; - } - - const query = api.getFoldersManager().generateDefaultFolderQuery(folder); - if (!query) { - return; - } - - await api.getViewManager().setViewByParametersWithExistingQuery({ - params: { - // Supports both 'folder' and 'folders' views. - view: this._action.advanced_camera_card_action, - query: new FolderViewQuery(query), - }, - }); - } -} diff --git a/src/card-controller/actions/actions/view.ts b/src/card-controller/actions/actions/view.ts index f7156fe1..2146033d 100644 --- a/src/card-controller/actions/actions/view.ts +++ b/src/card-controller/actions/actions/view.ts @@ -10,6 +10,11 @@ export class ViewAction extends AdvancedCameraCardAction { params: { view: this._action.advanced_camera_card_action, }, + ...(this._action.folder && { + queryExecutorOptions: { + folder: this._action.folder, + }, + }), }); } } diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index 64ef9d71..64860d3a 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -10,7 +10,6 @@ import { DefaultAction } from './actions/default'; import { DisplayModeSelectAction } from './actions/display-mode-select'; import { DownloadAction } from './actions/download'; import { ExpandAction } from './actions/expand'; -import { FoldersViewAction } from './actions/folders-view'; import { FullscreenAction } from './actions/fullscreen'; import { InternalCallbackAction } from './actions/internal-callback'; import { LogAction } from './actions/log'; @@ -86,6 +85,8 @@ export class ActionFactory { return new DefaultAction(context, action, options?.config); case 'clip': case 'clips': + case 'folder': + case 'folders': case 'image': case 'live': case 'recording': @@ -151,9 +152,6 @@ export class ActionFactory { return new StatusBarAction(context, action, options?.config); case INTERNAL_CALLBACK_ACTION: return new InternalCallbackAction(context, action, options?.config); - case 'folder': - case 'folders': - return new FoldersViewAction(context, action, options?.config); } /* istanbul ignore next: this path cannot be reached -- @preserve */ diff --git a/src/card-controller/folders/manager.ts b/src/card-controller/folders/manager.ts index 70b6ac7a..955221e6 100644 --- a/src/card-controller/folders/manager.ts +++ b/src/card-controller/folders/manager.ts @@ -1,6 +1,6 @@ import { cloneDeep } from 'lodash-es'; import { ConditionState } from '../../conditions/types'; -import { FolderConfig } from '../../config/schema/folders'; +import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders'; import { localize } from '../../localize/localize'; import { Endpoint } from '../../types'; import { ViewItem } from '../../view/item'; @@ -23,7 +23,7 @@ export class FoldersManager { this._folders.clear(); } - public addFolders(folders: FolderConfig[]): void { + public addFolders(folders: FolderConfigWithoutID[]): void { for (const folder of folders) { const folderNumber = this._folders.size; const id = folder.id ?? `folder/${folderNumber.toString()}`; diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts index 9686e2a1..c9fb61a1 100644 --- a/src/card-controller/view/query-executor.ts +++ b/src/card-controller/view/query-executor.ts @@ -140,13 +140,20 @@ export class QueryExecutor { return queryResults; } - public async executeDefaultFolderQuery( + public async executeFolderQuery( executorOptions?: QueryExecutorOptions, ): Promise { - const query = this._api.getFoldersManager().generateDefaultFolderQuery(); - return query - ? this._executeFolderQuery(new FolderViewQuery(query), executorOptions) - : null; + const folder = this._api.getFoldersManager().getFolder(executorOptions?.folder); + if (!folder) { + return null; + } + + const query = this._api.getFoldersManager().generateDefaultFolderQuery(folder); + if (!query) { + return null; + } + + return this._executeFolderQuery(new FolderViewQuery(query), executorOptions); } private async _executeFolderQuery( diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index b4ca9445..246e09b2 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -22,6 +22,7 @@ export interface QueryExecutorOptions { id?: string; func?: (media: ViewItem) => boolean; }; + folder?: string; rejectResults?: (results: QueryResults) => boolean; useCache?: boolean; } diff --git a/src/card-controller/view/view-query-executor.ts b/src/card-controller/view/view-query-executor.ts index 3c77ff7a..b929324f 100644 --- a/src/card-controller/view/view-query-executor.ts +++ b/src/card-controller/view/view-query-executor.ts @@ -86,8 +86,7 @@ export class ViewQueryExecutor { }; const executeFolderQuery = async (): Promise => { - const results = - await this._executor.executeDefaultFolderQuery(queryExecutorOptions); + const results = await this._executor.executeFolderQuery(queryExecutorOptions); return results ? [new SetQueryViewModifier(results)] : []; }; diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index d626c50c..c0530f61 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -15,7 +15,6 @@ import { MediaLoadedInfo } from '../types'; import { createCameraAction, createDisplayModeAction, - createFoldersViewAction, createGeneralAction, createMediaPlayerAction, createPTZControlsAction, @@ -658,8 +657,8 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: folder.title ?? localize('config.menu.buttons.folders'), style: isSelected ? this._getEmphasizedStyle() : {}, - tap_action: createFoldersViewAction('folders'), - hold_action: createFoldersViewAction('folder'), + tap_action: createViewAction('folders'), + hold_action: createViewAction('folder'), }; } @@ -674,8 +673,8 @@ export class MenuButtonController { icon: folder.icon ?? 'mdi:folder', selected: isSelected, style: isSelected ? this._getEmphasizedStyle() : {}, - tap_action: createFoldersViewAction('folders', { folderID: id }), - hold_action: createFoldersViewAction('folder', { folderID: id }), + tap_action: createViewAction('folders', { folderID: id }), + hold_action: createViewAction('folder', { folderID: id }), }; }); diff --git a/src/components-lib/timeline/controller.ts b/src/components-lib/timeline/controller.ts new file mode 100644 index 00000000..f5792f37 --- /dev/null +++ b/src/components-lib/timeline/controller.ts @@ -0,0 +1,1026 @@ +import { add, differenceInSeconds, sub } from 'date-fns'; +import { LitElement } from 'lit'; +import { isEqual, throttle } from 'lodash'; +import { ViewContext } from 'view'; +import { + IdType, + Timeline, + TimelineEventPropertiesResult, + TimelineFormatOption, + TimelineItem, + TimelineOptions, + TimelineOptionsCluster, + TimelineWindow, +} from 'vis-timeline'; +import { CameraManager } from '../../camera-manager/manager'; +import { rangesOverlap } from '../../camera-manager/range'; +import { MediaQuery } from '../../camera-manager/types'; +import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; +import { ViewItemManager } from '../../card-controller/view/item-manager'; +import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context'; +import { ViewManagerEpoch } from '../../card-controller/view/types'; +import { CameraConfig } from '../../config/schema/cameras'; +import { AdvancedCameraCardView } from '../../config/schema/common/const'; +import { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails'; +import { + TimelineCoreConfig, + TimelinePanMode, +} from '../../config/schema/common/controls/timeline'; +import { configDefaults } from '../../config/schema/types'; +import { HomeAssistant } from '../../ha/types'; +import { stopEventFromActivatingCardWideActions } from '../../utils/action'; +import { + formatDateAndTime, + isHoverableDevice, + isTruthy, + setOrRemoveAttribute, +} from '../../utils/basic'; +import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; +import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; +import { ViewMedia } from '../../view/item'; +import { ViewItemClassifier } from '../../view/item-classifier'; +import { EventMediaQuery, MediaQueries, RecordingMediaQuery } from '../../view/query'; +import { QueryClassifier, QueryType } from '../../view/query-classifier'; +import { QueryResults } from '../../view/query-results'; +import { mergeViewContext } from '../../view/view'; +import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source'; +import { + ExtendedTimeline, + TimelineItemClickAction, + TimelineKey, + TimelineRangeChange, +} from './types'; + +// An event used to fetch data required for thumbnail rendering. See special +// note below on why this is necessary. +interface ThumbnailDataRequest { + item: IdType; + hass?: HomeAssistant; + cameraManager?: CameraManager; + cameraConfig?: CameraConfig; + media?: ViewMedia; + viewManagerEpoch?: ViewManagerEpoch; + viewItemManager?: ViewItemManager; +} + +class ThumbnailDataRequestEvent extends CustomEvent {} + +interface TimelineControllerOptions { + hass?: HomeAssistant; + cameraManager?: CameraManager; + viewItemManager?: ViewItemManager; + timelineConfig?: TimelineCoreConfig; + mini?: boolean; + thumbnailConfig?: ThumbnailsControlBaseConfig; + keys?: TimelineKey[]; +} + +const TIMELINE_TARGET_BAR_ID = 'target_bar'; + +export class TimelineController { + private _host: LitElement; + private _timelineElement: HTMLElement | null = null; + + private _source: TimelineDataSource | null = null; + private _timeline: ExtendedTimeline | null = null; + + private _hass: HomeAssistant | null = null; + private _cameraManager: CameraManager | null = null; + private _viewItemManager: ViewItemManager | null = null; + private _viewManagerEpoch: ViewManagerEpoch | null = null; + private _timelineConfig: TimelineCoreConfig | null = null; + private _mini = false; + + private _panMode: TimelinePanMode | null = null; + private _targetBarVisible = false; + private _itemClickAction: TimelineItemClickAction = 'play'; + + private _thumbnailConfig: ThumbnailsControlBaseConfig | null = null; + + private _keys: TimelineKey[] = []; + + private readonly _isHoverableDevice = isHoverableDevice(); + + // Range changes are volumonous: throttle the calls on seeking. + private _throttledSetViewDuringRangeChange = throttle( + this._setViewDuringRangeChange.bind(this), + 1000 / 10, + ); + + // Need a way to separate when a user clicks (to pan the timeline) vs when a + // user clicks (to choose a recording (non-event) to play). + private _pointerHeld: + | (TimelineEventPropertiesResult & { window?: TimelineWindow }) + | null = null; + private _ignoreClick = false; + + constructor(host: LitElement) { + this._host = host; + } + + public setHass(hass: HomeAssistant | null): void { + this._hass = hass; + } + + public destroyTimeline(): void { + this._timeline?.destroy(); + this._timeline = null; + this._targetBarVisible = false; + this._pointerHeld = null; + } + + public setOptions(options: TimelineControllerOptions): void { + if ( + this._cameraManager !== (options?.cameraManager ?? null) || + this._timelineConfig !== (options?.timelineConfig ?? null) || + !isEqual(this._keys, options?.keys ?? null) + ) { + this.destroyTimeline(); + + if (options.keys && options.cameraManager && options.timelineConfig) { + this._source = new TimelineDataSource( + options.cameraManager, + options.keys, + options.timelineConfig.events_media_type, + options.timelineConfig.show_recordings, + ); + } else { + this._source = null; + } + } + + if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) { + if (options.thumbnailConfig) { + this._host.style.setProperty( + '--advanced-camera-card-thumbnail-size', + `${options?.thumbnailConfig.size}px`, + ); + } else { + this._host.style.removeProperty('--advanced-camera-card-thumbnail-size'); + } + } + + if (this._timelineConfig !== (options.timelineConfig ?? null)) { + this._timelineConfig = options?.timelineConfig ?? null; + + setOrRemoveAttribute( + this._host, + !!this._timelineConfig?.show_recordings, + 'recordings', + ); + setOrRemoveAttribute( + this._host, + this._timelineConfig?.style === 'ribbon', + 'ribbon', + ); + setOrRemoveAttribute(this._host, this._timelineConfig?.style === 'stack', 'stack'); + } + + this._thumbnailConfig = options?.thumbnailConfig ?? null; + this._cameraManager = options?.cameraManager ?? null; + this._viewItemManager = options?.viewItemManager ?? null; + this._timelineConfig = options?.timelineConfig ?? null; + this._mini = options?.mini ?? false; + this._keys = options?.keys ?? []; + + setOrRemoveAttribute(this._host, !this._mini || this._keys.length > 1, 'groups'); + } + + public async setView(viewManagerEpoch: ViewManagerEpoch | null): Promise { + if (this._viewManagerEpoch === viewManagerEpoch) { + return; + } + + this._viewManagerEpoch = viewManagerEpoch ?? null; + await this._updateTimelineFromView(); + } + + public handleThumbnailDataRequest = (request: ThumbnailDataRequestEvent): void => { + const itemID = request.detail.item; + const media = this._source?.dataset.get(itemID)?.media; + const cameraConfig = media + ? this._cameraManager?.getStore().getCameraConfigForMedia(media) ?? undefined + : undefined; + + request.detail.hass = this._hass ?? undefined; + request.detail.cameraConfig = cameraConfig; + request.detail.cameraManager = this._cameraManager ?? undefined; + request.detail.viewItemManager = this._viewItemManager ?? undefined; + request.detail.media = media; + request.detail.viewManagerEpoch = this._viewManagerEpoch ?? undefined; + }; + + public getEffectivePanMode(): TimelinePanMode { + return this._panMode ?? this._timelineConfig?.pan_mode ?? 'pan'; + } + + public cyclePanMode(): void { + const panMode = this.getEffectivePanMode(); + this._panMode = + panMode === 'pan' + ? 'seek' + : panMode === 'seek' + ? 'seek-in-media' + : panMode === 'seek-in-media' + ? 'seek-in-camera' + : 'pan'; + this._host.requestUpdate(); + } + + public setTimelineDate(date: Date): void { + this._timeline?.moveTo(date); + } + + public shouldSupportSeeking(): boolean { + return this._mini; + } + + public setTimelineElement(element?: HTMLElement): boolean { + if ( + !this._source || + !this._timelineConfig || + (this._timeline && this._timelineElement === (element ?? null)) + ) { + return false; + } + + this.destroyTimeline(); + this._timelineElement = element ?? null; + + if (!this._timelineElement) { + return false; + } + + const options = this._getOptions(); + if (!options) { + return false; + } + + if (this._shouldShowGroups()) { + this._timeline = new Timeline( + this._timelineElement, + this._source.dataset, + options, + ); + } else { + this._timeline = new Timeline( + this._timelineElement, + this._source.dataset, + this._source.groups, + options, + ); + } + + this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); + this._timeline.on('click', this._timelineClickHandler.bind(this)); + this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); + + // This complexity exists to ensure we can tell between a click that + // causes the timeline zoom/range to change, and a 'static' click on the + // // timeline (which may need to trigger a card wide event). + this._timeline.on('mouseDown', (ev: TimelineEventPropertiesResult) => { + const window = this._timeline?.getWindow(); + this._pointerHeld = { + ...ev, + ...(window && { window: window }), + }; + this._ignoreClick = false; + }); + this._timeline.on('mouseUp', () => { + this._pointerHeld = null; + this._removeTargetBar(); + }); + + return true; + } + + private _shouldShowGroups(): boolean { + return !this._mini || this._keys.length > 1; + } + + private _setTargetBarAppropriately(targetTime: Date): void { + if (!this._timeline) { + return; + } + + const view = this._viewManagerEpoch?.manager.getView(); + const panMode = this.getEffectivePanMode(); + const targetBarOn = + this.shouldSupportSeeking() && + (panMode === 'seek' || + ((panMode === 'seek-in-camera' || panMode === 'seek-in-media') && + this._timeline.getSelection().some((id) => { + const item = this._source?.dataset?.get(id); + return ( + panMode !== 'seek-in-camera' || + item?.media?.getCameraID() === view?.camera, + item && + item.start && + item.end && + targetTime.getTime() >= item.start && + targetTime.getTime() <= item.end + ); + }))); + + if (targetBarOn) { + if (!this._targetBarVisible) { + this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = true; + } else { + this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + } + + const window = this._timeline.getWindow(); + const markerProportion = + (targetTime.getTime() - window.start.getTime()) / + (window.end.getTime() - window.start.getTime()); + + // Position the marker proportionally to how 'far' the pointer is being + // held relative to the timeline window. + this._host.setAttribute( + 'target-bar-marker-direction', + markerProportion < 0.25 ? 'right' : markerProportion > 0.75 ? 'left' : 'center', + ); + this._timeline?.setCustomTimeMarker?.( + formatDateAndTime(targetTime, true), + TIMELINE_TARGET_BAR_ID, + ); + } else { + this._removeTargetBar(); + } + } + + private _removeTargetBar(): void { + this._host.removeAttribute('target-bar-direction'); + if (this._targetBarVisible) { + this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = false; + } + } + + /** + * Called whenever the range is in the process of being changed. + * @param properties + */ + private _timelineRangeChangeHandler(properties: TimelineRangeChange): void { + if (this._pointerHeld) { + this._ignoreClick = true; + } + + if ( + this.shouldSupportSeeking() && + this._timeline && + properties.byUser && + // Do not adjust select/seek media during zoom events. + properties.event.type !== 'wheel' && + properties.event.additionalEvent !== 'pinchin' && + properties.event.additionalEvent !== 'pinchout' + ) { + const targetTime = this._pointerHeld?.window + ? add(properties.start, { + seconds: + (this._pointerHeld.time.getTime() - + this._pointerHeld.window.start.getTime()) / + 1000, + }) + : properties.end; + + if (this._pointerHeld) { + this._setTargetBarAppropriately(targetTime); + } + + this._throttledSetViewDuringRangeChange(targetTime, properties); + } + } + + private async _setViewDuringRangeChange( + targetTime: Date, + properties: TimelineRangeChange, + ): Promise { + const view = this._viewManagerEpoch?.manager.getView(); + const results = view?.queryResults; + const media = results?.getResults(); + const panMode = this.getEffectivePanMode(); + if ( + !media || + !results || + !this._timeline || + !view || + !this._hass || + !this._cameraManager || + panMode === 'pan' + ) { + return; + } + + const canSeek = this.shouldSupportSeeking(); + let newResults: QueryResults | null = null; + + if (panMode === 'seek') { + newResults = results + .clone() + .selectBestResult( + (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime, view?.camera), + { + allCameras: true, + main: true, + }, + ); + } else if (panMode === 'seek-in-camera') { + newResults = results + .clone() + .selectBestResult( + (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime), + { + cameraID: view.camera, + }, + ) + .promoteCameraSelectionToMainSelection(view.camera); + } else if (panMode === 'seek-in-media') { + newResults = results; + } + + const desiredView: AdvancedCameraCardView = this._mini + ? targetTime >= new Date() + ? 'live' + : 'media' + : view.view; + + const selectedItem = newResults?.getSelectedResult(); + const selectedCamera = ViewItemClassifier.isMedia(selectedItem) + ? selectedItem.getCameraID() + : null; + + this._viewManagerEpoch?.manager.setViewByParameters({ + params: { + ...(selectedCamera && { camera: selectedCamera }), + view: desiredView, + queryResults: newResults, + }, + modifiers: [ + new MergeContextViewModifier({ + ...(canSeek && { mediaViewer: { seek: targetTime } }), + ...this._getTimelineContext({ start: properties.start, end: properties.end }), + }), + ], + }); + } + + private _getTimelineContext(window?: TimelineWindow): ViewContext { + const view = this._viewManagerEpoch?.manager.getView(); + const newWindow = window ?? this._timeline?.getWindow(); + return { + timeline: { + ...view?.context?.timeline, + ...(newWindow && { window: newWindow }), + }, + }; + } + + private async _timelineClickHandler( + properties: TimelineEventPropertiesResult, + ): Promise { + // Calls to stopEventFromActivatingCardWideActions() are included for + // completeness. Timeline does not support card-wide events and they are + // disabled in card.ts in `_getMergedActions`. + if ( + this._ignoreClick || + (properties.what && + ['item', 'background', 'group-label', 'axis'].includes(properties.what)) + ) { + stopEventFromActivatingCardWideActions(properties.event); + } + + const view = this._viewManagerEpoch?.manager.getView(); + + if ( + this._ignoreClick || + !view || + !this._viewManagerEpoch || + !this._source || + !properties.what + ) { + return; + } + + let drawerAction: 'open' | 'close' = 'close'; + + if ( + this._timelineConfig?.show_recordings && + properties.time && + ['background', 'axis'].includes(properties.what) + ) { + const query = this._createMediaQueries('recording'); + if (query) { + await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + baseView: view, + params: { view: 'recording', query: query }, + queryExecutorOptions: { + selectResult: { + time: { + time: properties.time, + }, + }, + }, + }); + } + } else if (properties.item && properties.what === 'item') { + const cameraID = String(properties.group); + const id = String(properties.item); + + const criteria = { + main: true, + ...(cameraID && view.isGrid() && { cameraID: cameraID }), + }; + const newResults = view.queryResults + ?.clone() + .resetSelectedResult() + .selectResultIfFound((media) => media.getID() === properties.item, criteria); + + const context: ViewContext = mergeViewContext(this._getTimelineContext(), { + mediaViewer: { seek: properties.time }, + }); + + if (!newResults || !newResults.hasSelectedResult()) { + // This can happen in a few situations: + // - If this is a recording query (with recorded hours) and an event is + // clicked on the timeline + // - If the current thumbnails/results is a filtered view from the media + // gallery (i.e. any case where the thumbnails may not be match the + // events on the timeline, e.g. in the snapshots viewer but + // mini-timeline showing all media). + const query = this._createMediaQueries('event'); + if (query) { + await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { view: 'media', query: query }, + queryExecutorOptions: { + selectResult: { + id: id, + }, + rejectResults: (results) => !results.hasResults(), + }, + modifiers: [new MergeContextViewModifier(context)], + }); + } + } else { + this._viewManagerEpoch.manager.setViewByParameters({ + params: { + queryResults: newResults, + view: this._itemClickAction === 'play' ? 'media' : view.view, + }, + modifiers: [new MergeContextViewModifier(context)], + }); + } + + if (this._itemClickAction === 'select') { + drawerAction = 'open'; + } + } + + fireAdvancedCameraCardEvent(this._host, `thumbnails:${drawerAction}`); + + this._ignoreClick = false; + } + + /** + * Get a broader prefetch window from a start and end basis. + * @param window The window to broaden. + * @returns A broader timeline. + */ + private _getPrefetchWindow(window: TimelineWindow): TimelineWindow { + const delta = differenceInSeconds(window.end, window.start); + return { + start: sub(window.start, { seconds: delta }), + end: add(window.end, { seconds: delta }), + }; + } + + private _createMediaQueries( + type: QueryType, + options?: { + window?: TimelineWindow; + }, + ): MediaQueries | null { + if (!this._timeline || !this._source) { + return null; + } + + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes( + this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()), + ); + + if (type === 'event') { + const queries = this._source.getTimelineEventQueries(cacheFriendlyWindow); + return queries ? new EventMediaQuery(queries) : null; + } else if (type === 'recording') { + const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow); + return queries ? new RecordingMediaQuery(queries) : null; + } + return null; + } + + private _timelineRangeChangedHandler = async (properties: { + start: Date; + end: Date; + byUser: boolean; + event: Event & { additionalEvent: string }; + }): Promise => { + this._removeTargetBar(); + const view = this._viewManagerEpoch?.manager.getView(); + + if ( + !this._timeline || + !view || + // When in mini mode, something else is in charge of the primary media + // population (e.g. the live view), in this case only act when the user + // themselves are interacting with the timeline. + (this._mini && !properties.byUser) + ) { + return; + } + + await this._source?.refresh(this._getPrefetchWindow(properties)); + + const queryType = QueryClassifier.getQueryType(view.query); + if (!queryType) { + return; + } + const mediaQuery = this._createMediaQueries(queryType); + if (!mediaQuery || this._alreadyHasAcceptableMediaQuery(mediaQuery)) { + return; + } + + await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { + query: mediaQuery, + }, + queryExecutorOptions: { + selectResult: { + id: + this._viewManagerEpoch?.manager + .getView() + ?.queryResults?.getSelectedResult() + ?.getID() ?? undefined, + }, + }, + modifiers: [new MergeContextViewModifier(this._getTimelineContext())], + }); + }; + + private _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { + const view = this._viewManagerEpoch?.manager.getView(); + const query = view?.query; + + if (!this._cameraManager || !query || !QueryClassifier.isMediaQuery(query)) { + return false; + } + + const currentQueries = query?.getQuery(); + const currentResultTimestamp = view?.queryResults?.getResultsTimestamp(); + + return ( + !!currentQueries && + !!currentResultTimestamp && + !!query?.isSupersetOf(freshMediaQuery) && + this._cameraManager.areMediaQueriesResultsFresh( + currentQueries, + currentResultTimestamp, + ) + ); + } + + private async _updateTimelineFromView(): Promise { + const view = this._viewManagerEpoch?.manager.getView(); + if (!view || !this._timelineConfig || !this._source || !this._timeline) { + return; + } + + const timelineWindow = this._timeline.getWindow(); + + // Calculate the timeline window to show. If there is a window set in the + // view context, always honor that. Otherwise, if there's a selected media + // item that is already within the current window (even if it's not + // perfectly positioned) -- leave it as is. Otherwise, change the window to + // perfectly center on the media. + + let desiredWindow = timelineWindow; + const item = view.queryResults?.getSelectedResult(); + const media = item && ViewItemClassifier.isMedia(item) ? item : null; + const mediaStartTime = media?.getStartTime() ?? null; + const mediaEndTime = media?.getEndTime() ?? null; + const mediaIsEvent = media ? ViewItemClassifier.isEvent(media) : false; + + const mediaWindow: TimelineWindow | null = + media && mediaStartTime + ? // If this media has no end time, it's just a "point" in time so the + // range effectively starts/ends at the same time. + { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime } + : null; + const context = view.context?.timeline; + + if (context && context.window) { + desiredWindow = context.window; + } else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { + const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime( + mediaIsEvent, + mediaStartTime, + mediaEndTime, + ); + if (perfectMediaWindow) { + desiredWindow = perfectMediaWindow; + } + } + const prefetchedWindow = this._getPrefetchWindow(desiredWindow); + + if (!this._pointerHeld) { + // Don't fetch any data or touch the timeline in any way if the user is + // currently interacting with it. Without this the subsequent data fetches + // (via fetchIfNecessary) may update the timeline contents which causes + // the visjs timeline to stop dragging/panning operations which is very + // disruptive to the user. + await this._source?.refresh(prefetchedWindow, view); + + this._source.addEventMediaToDataset(view.queryResults?.getResults()); + } + + const currentSelection = this._timeline.getSelection(); + const mediaIDsToSelect = this._getAllSelectedMediaIDsFromView(); + + const needToSelect = mediaIDsToSelect.some( + (mediaID) => !currentSelection.includes(mediaID), + ); + + if (needToSelect) { + if (this._isClustering()) { + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. + + for (const mediaID of mediaIDsToSelect) { + // Need to this rewrite prior to setting the selection (just below), or + // the selection will be lost on rewrite. + this._source?.rewriteEvent(mediaID); + } + } + + this._timeline?.setSelection(mediaIDsToSelect, { + focus: false, + animation: { + animation: false, + zoom: false, + }, + }); + } + + // Set the timeline window if necessary. + if (!this._pointerHeld && !isEqual(desiredWindow, timelineWindow)) { + this._timeline.setWindow(desiredWindow.start, desiredWindow.end); + } + + // Only generate thumbnails if the existing query is not an acceptable + // match, to avoid getting stuck in a loop (the subsequent fetches will not + // actually fetch since the data will have been cached). + // + // Timeline receives a new `view` + // -> Events fetched + // -> Thumbnails generated + // -> New view dispatched (to load thumbnails into outer carousel). + // -> New view received ... [loop] + // + // Also don't generate thumbnails in mini-timelines (they will already have + // been generated). + + const queryType = QueryClassifier.getQueryType(view.query); + if (!queryType) { + return; + } + + const freshMediaQuery = this._createMediaQueries(queryType, { + window: desiredWindow, + }); + + if ( + !this._mini && + freshMediaQuery && + !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) + ) { + const currentlySelectedResult = this._viewManagerEpoch?.manager + .getView() + ?.queryResults?.getSelectedResult(); + + await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { + query: freshMediaQuery, + }, + queryExecutorOptions: { + selectResult: { + id: currentlySelectedResult?.getID() ?? undefined, + }, + }, + modifiers: [ + new MergeContextViewModifier(this._getTimelineContext(desiredWindow)), + ], + }); + } + } + + private _getAllSelectedMediaIDsFromView(): IdType[] { + const view = this._viewManagerEpoch?.manager.getView(); + return ( + view?.queryResults?.getMultipleSelectedResults({ + main: true, + ...(view.isGrid() && { allCameras: true }), + }) ?? [] + ) + .filter((media) => ViewItemClassifier.isEvent(media)) + .map((media) => media.getID()) + .filter(isTruthy); + } + + private _isClustering(): boolean { + return ( + this._timelineConfig?.style === 'stack' && + !!this._timelineConfig?.clustering_threshold && + this._timelineConfig.clustering_threshold > 0 + ); + } + + private _getPerfectWindowFromMediaStartAndEndTime( + isEvent: boolean, + startTime: Date | null, + endTime: Date | null, + ): TimelineWindow | null { + if (isEvent) { + const windowSeconds = this._getConfiguredWindowSeconds(); + + if (startTime && endTime) { + if (endTime.getTime() - startTime.getTime() > windowSeconds * 1000) { + // If the event is larger than the configured window, only show the most + // recent portion of the event that fits in the window. + return { + start: sub(endTime, { seconds: windowSeconds }), + end: endTime, + }; + } else { + // If the event is shorter than the configured window, center the event + // in the window. + const gap = windowSeconds - (endTime.getTime() - startTime.getTime()) / 1000; + return { + start: sub(startTime, { seconds: gap / 2 }), + end: add(endTime, { seconds: gap / 2 }), + }; + } + } else if (startTime) { + // If there's no end-time yet, place the start-time in the center of the + // time window. + return { + start: sub(startTime, { seconds: windowSeconds / 2 }), + end: add(startTime, { seconds: windowSeconds / 2 }), + }; + } + } else if (startTime && endTime) { + return { + start: startTime, + end: endTime, + }; + } + return null; + } + + private _getConfiguredWindowSeconds(): number { + return ( + this._timelineConfig?.window_seconds ?? configDefaults.timeline.window_seconds + ); + } + + /** + * Get desired timeline start/end time. + * @returns A tuple of start/end date. + */ + private _getDefaultStartEnd(): TimelineWindow { + const end = new Date(); + const start = sub(end, { + seconds: this._getConfiguredWindowSeconds(), + }); + return { start: start, end: end }; + } + + private _getDateTimeFormat(): TimelineFormatOption { + const format24Hour = !!this._timelineConfig?.format?.['24h']; + + // See: https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options + return { + minorLabels: { + minute: format24Hour ? 'HH:mm' : 'h:mm A', + hour: format24Hour ? 'HH:mm' : 'h:mm A', + }, + majorLabels: { + millisecond: format24Hour ? 'HH:mm:ss' : 'h:mm:ss A', + second: format24Hour ? 'D MMMM HH:mm' : 'D MMMM h:mm A', + }, + }; + } + + private _getOptions(): TimelineOptions | null { + if (!this._timelineConfig) { + return null; + } + + const defaultWindow = this._getDefaultStartEnd(); + const stack = this._timelineConfig.style === 'stack'; + // Configuration for the Timeline, see: + // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options + return { + cluster: this._isClustering() + ? { + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: this._timelineConfig.clustering_threshold, + + clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { + const selectedIDs = this._getAllSelectedMediaIDsFromView(); + const firstMedia = (first).media; + const secondMedia = (second).media; + + // Never include the currently selected item in a cluster, and + // never group different object types together (e.g. person and + // car). + return ( + first.type !== 'background' && + first.type === second.type && + !selectedIDs.includes(first.id) && + !selectedIDs.includes(second.id) && + !!firstMedia && + !!secondMedia && + ViewItemClassifier.isEvent(firstMedia) && + ViewItemClassifier.isEvent(secondMedia) && + firstMedia.isGroupableWith(secondMedia) + ); + }, + } + : // Timeline type information is incorrect requiring this 'as'. + (false as unknown as TimelineOptionsCluster), + minHeight: '100%', + maxHeight: '100%', + zoomMax: 1 * 24 * 60 * 60 * 1000, + zoomMin: 1 * 1000, + margin: { + item: { + // In ribbon mode, a 20px item is reduced to 6px, so need to add a + // 14px margin to ensure items line up with subgroups. + vertical: stack ? 10 : 24, + }, + }, + selectable: true, + stack: stack, + start: defaultWindow.start, + end: defaultWindow.end, + groupHeightMode: 'auto', + tooltip: { + followMouse: true, + overflowMethod: 'cap', + template: this._getTooltip.bind(this), + }, + format: this._getDateTimeFormat(), + xss: { + disabled: false, + filterOptions: { + whiteList: { + 'advanced-camera-card-timeline-thumbnail': ['details', 'item'], + div: ['title'], + span: ['style'], + }, + }, + }, + }; + } + + /** + * Get a tooltip for a given timeline event. + * @param item The TimelineItem in question. + * @returns The tooltip as a string to render. + */ + private _getTooltip(item: TimelineItem): string { + if (!this._isHoverableDevice) { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + return ''; + } + + // Cannot use Lit data-bindings as visjs requires a string for tooltips. + // Note that changes to attributes here must be mirrored in the xss + // whitelist in `_getOptions()` . + return ` + + `; + } +} diff --git a/src/components-lib/timeline-source.ts b/src/components-lib/timeline/source.ts similarity index 65% rename from src/components-lib/timeline-source.ts rename to src/components-lib/timeline/source.ts index e77ccad9..b2194714 100644 --- a/src/components-lib/timeline-source.ts +++ b/src/components-lib/timeline/source.ts @@ -1,18 +1,27 @@ import { add, sub } from 'date-fns'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; -import { CameraManager } from '../camera-manager/manager'; +import { CameraManager } from '../../camera-manager/manager'; import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet, -} from '../camera-manager/range'; -import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types'; -import { capEndDate } from '../camera-manager/utils/cap-end-date'; -import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly'; -import { ClipsOrSnapshotsOrAll } from '../types'; -import { errorToConsole, ModifyInterface } from '../utils/basic.js'; -import { ViewMedia } from '../view/item'; +} from '../../camera-manager/range'; +import { + EventQuery, + RecordingQuery, + RecordingSegment, +} from '../../camera-manager/types'; +import { capEndDate } from '../../camera-manager/utils/cap-end-date'; +import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; +import { FolderConfig } from '../../config/schema/folders'; +import { ClipsOrSnapshotsOrAll } from '../../types'; +import { errorToConsole, ModifyInterface } from '../../utils/basic.js'; +import { ViewItem, ViewMedia } from '../../view/item'; +import { ViewItemClassifier } from '../../view/item-classifier'; +import { QueryClassifier } from '../../view/query-classifier'; +import { View } from '../../view/view'; +import { TimelineKey } from './types'; // Allow timeline freshness to be at least this number of seconds out of date // (caching times in the data-engine may increase the effective delay). @@ -36,32 +45,44 @@ export interface AdvancedCameraCardTimelineItem extends TimelineItem { media?: ViewMedia; } +interface AdvancedCameraCardGroup { + id: string; + content: string; +} + export class TimelineDataSource { - protected _cameraManager: CameraManager; - protected _dataset: DataSet = new DataSet(); + private _cameraManager: CameraManager; + private _dataset: DataSet = new DataSet(); + private _groups: DataSet; // The ranges in which recordings have been calculated and added for. // Calculating recordings is a very expensive process since it is based on // segments (not just the fetch is expensive, but the JS to dedup and turn the // high-N segments into a smaller number of consecutive recording blocks). - protected _recordingRanges = new MemoryRangeSet(); + private _recordingRanges = new MemoryRangeSet(); // Cache event ranges since re-adding the same events is a timeline // performance killer (even if the request results are cached). - protected _eventRanges = new ExpiringMemoryRangeSet(); + private _eventRanges = new ExpiringMemoryRangeSet(); - protected _cameraIDs: Set; - protected _eventsMediaType: ClipsOrSnapshotsOrAll; - protected _showRecordings: boolean; + private _cameraIDs: Set; + + private _eventsMediaType: ClipsOrSnapshotsOrAll; + private _showRecordings: boolean; constructor( cameraManager: CameraManager, - cameraIDs: Set, + keys: TimelineKey[], eventsMediaType: ClipsOrSnapshotsOrAll, showRecordings: boolean, ) { this._cameraManager = cameraManager; - this._cameraIDs = cameraIDs; + + this._cameraIDs = new Set( + keys.filter((key) => key.type === 'camera').map((key) => key.cameraID), + ); + this._groups = this._generateGroups(keys); + this._eventsMediaType = eventsMediaType; this._showRecordings = showRecordings; } @@ -70,6 +91,41 @@ export class TimelineDataSource { return this._dataset; } + private _getGroupIDForCamera(cameraID: string): string { + return `camera/${cameraID}`; + } + + private _getGroupIDForFolder(folderConfig: FolderConfig): string { + return folderConfig.id; + } + + private _generateGroups(keys: TimelineKey[]): DataSet { + const groups: AdvancedCameraCardGroup[] = []; + for (const key of keys) { + /* istanbul ignore else: the else path cannot be reached as key can only + be {camera, folder} -- @preserve */ + if (key.type === 'camera') { + const cameraMetadata = this._cameraManager.getCameraMetadata(key.cameraID); + + groups.push({ + id: this._getGroupIDForCamera(key.cameraID), + content: cameraMetadata?.title ?? key.cameraID, + }); + } else if (key.type === 'folder') { + const folderID = this._getGroupIDForFolder(key.folder); + groups.push({ + id: folderID, + content: key.folder.title ?? folderID, + }); + } + } + return new DataSet(groups); + } + + get groups(): DataSet { + return this._groups; + } + public rewriteEvent(id: IdType): void { // Hack: For timeline uses of the event dataset clustering may not update // unless the dataset changes, artifically update the dataset to ensure the @@ -85,39 +141,48 @@ export class TimelineDataSource { } } - public async refresh(window: TimelineWindow): Promise { - try { - await Promise.all([ - this._refreshEvents(window), - ...(this._showRecordings ? [this._refreshRecordings(window)] : []), - ]); - } catch (e) { - errorToConsole(e as Error); + public addEventMediaToDataset(mediaArray?: ViewItem[] | null): void { + const data: AdvancedCameraCardTimelineItem[] = []; - // Intentionally ignore errors here, since it is likely the user will - // change the range again and a subsequent call may work. To do otherwise - // would be jarring to the timeline experience in the case of transient - // errors from the backend. + for (const media of mediaArray ?? []) { + if (!ViewItemClassifier.isEvent(media)) { + continue; + } + + const startTime = media.getStartTime(); + const id = media.getID(); + const folder = media.getFolder(); + const cameraID = media.getCameraID(); + const groupID = folder + ? this._getGroupIDForFolder(folder) + : cameraID + ? this._getGroupIDForCamera(cameraID) + : null; + if (id && startTime && groupID) { + data.push({ + id: id, + group: groupID, + content: '', + media: media, + start: startTime.getTime(), + type: 'range', + end: media.getUsableEndTime()?.getTime(), + }); + } } + + this._dataset.update(data); } - public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { - return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { - start: window.start, - end: window.end, - ...(this._eventsMediaType === 'clips' && { hasClip: true }), - ...(this._eventsMediaType === 'snapshots' && { hasSnapshot: true }), - }); + private _shouldUseEventsFromView(view?: View): boolean { + return QueryClassifier.isEventQuery(view?.query); } - public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null { - return this._cameraManager.generateDefaultRecordingQueries(this._cameraIDs, { - start: window.start, - end: window.end, - }); - } + private async _refreshEvents(window: TimelineWindow, view?: View): Promise { + if (this._shouldUseEventsFromView(view)) { + return; + } - protected async _refreshEvents(window: TimelineWindow): Promise { if ( this._eventRanges.hasCoverage({ start: window.start, @@ -134,25 +199,9 @@ export class TimelineDataSource { return; } - const mediaArray = await this._cameraManager.executeMediaQueries(eventQueries); - const data: AdvancedCameraCardTimelineItem[] = []; - for (const media of mediaArray ?? []) { - const startTime = media.getStartTime(); - const id = media.getID(); - const cameraID = media.getCameraID(); - if (id && startTime && cameraID) { - data.push({ - id: id, - group: cameraID, - content: '', - media: media, - start: startTime.getTime(), - type: 'range', - end: media.getUsableEndTime()?.getTime() ?? startTime.getTime(), - }); - } - } - this._dataset.update(data); + this.addEventMediaToDataset( + await this._cameraManager.executeMediaQueries(eventQueries), + ); this._eventRanges.add({ ...cacheFriendlyWindow, @@ -160,7 +209,49 @@ export class TimelineDataSource { }); } - protected async _refreshRecordings(window: TimelineWindow): Promise { + public async refresh(window: TimelineWindow, view?: View): Promise { + try { + await Promise.all([ + this._refreshEvents(window, view), + ...(this._showRecordings ? [this._refreshRecordings(window)] : []), + ]); + } catch (e) { + errorToConsole(e as Error); + + // Intentionally ignore errors here, since it is likely the user will + // change the range again and a subsequent call may work. To do otherwise + // would be jarring to the timeline experience in the case of transient + // errors from the backend. + } + } + + public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { + if (!this._cameraIDs.size) { + return null; + } + return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { + start: window.start, + end: window.end, + ...(this._eventsMediaType === 'clips' && { hasClip: true }), + ...(this._eventsMediaType === 'snapshots' && { hasSnapshot: true }), + }); + } + + public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null { + if (!this._cameraIDs.size) { + return null; + } + return this._cameraManager.generateDefaultRecordingQueries(this._cameraIDs, { + start: window.start, + end: window.end, + }); + } + + private async _refreshRecordings(window: TimelineWindow): Promise { + if (!this._cameraIDs.size) { + return; + } + type AdvancedCameraCardTimelineItemWithEnd = ModifyInterface< AdvancedCameraCardTimelineItem, { end: number } @@ -172,7 +263,7 @@ export class TimelineDataSource { ): AdvancedCameraCardTimelineItemWithEnd => { return { id: `recording-${cameraID}-${segment.id}`, - group: cameraID, + group: this._getGroupIDForCamera(cameraID), start: segment.start_time * 1000, end: segment.end_time * 1000, content: '', @@ -183,16 +274,18 @@ export class TimelineDataSource { const getExistingRecordingsForCameraID = ( cameraID: string, ): AdvancedCameraCardTimelineItemWithEnd[] => { + const groupID = this._getGroupIDForCamera(cameraID); return this._dataset.get({ filter: (item) => - item.type == 'background' && item.group === cameraID && item.end !== undefined, + item.type === 'background' && item.group === groupID && item.end !== undefined, }) as AdvancedCameraCardTimelineItemWithEnd[]; }; const deleteRecordingsForCameraID = (cameraID: string): void => { + const groupID = this._getGroupIDForCamera(cameraID); this._dataset.remove( this._dataset.get({ - filter: (item) => item.type === 'background' && item.group === cameraID, + filter: (item) => item.type === 'background' && item.group === groupID, }), ); }; diff --git a/src/components-lib/timeline/types.ts b/src/components-lib/timeline/types.ts new file mode 100644 index 00000000..11a4cf0d --- /dev/null +++ b/src/components-lib/timeline/types.ts @@ -0,0 +1,54 @@ +import { DateType, IdType, Timeline, TimelineWindow } from 'vis-timeline'; +import { CameraManager } from '../../camera-manager/manager'; +import { ViewItemManager } from '../../card-controller/view/item-manager'; +import { ViewManagerEpoch } from '../../card-controller/view/types'; +import { CameraConfig } from '../../config/schema/cameras'; +import { FolderConfig } from '../../config/schema/folders'; +import { HomeAssistant } from '../../ha/types'; +import { ViewMedia } from '../../view/item'; + +// An event used to fetch data required for thumbnail rendering. See special +// note in AdvancedCameraCardTimelineThumbnail on why this is necessary. +export interface ThumbnailDataRequest { + item: IdType; + hass?: HomeAssistant; + cameraManager?: CameraManager; + cameraConfig?: CameraConfig; + media?: ViewMedia; + viewManagerEpoch?: ViewManagerEpoch; + viewItemManager?: ViewItemManager; +} + +export class ThumbnailDataRequestEvent extends CustomEvent {} + +interface CameraTimelineKey { + type: 'camera'; + cameraID: string; +} +interface FolderTimelineKey { + type: 'folder'; + folder: FolderConfig; +} +export type TimelineKey = CameraTimelineKey | FolderTimelineKey; + +export interface ExtendedTimeline extends Timeline { + // setCustomTimeMarker currently missing from Timeline types. + setCustomTimeMarker?(time: DateType, id?: IdType): void; +} + +export interface TimelineRangeChange extends TimelineWindow { + event: Event & { additionalEvent?: string }; + byUser: boolean; +} + +export type TimelineItemClickAction = 'play' | 'select'; + +interface TimelineViewContext { + window?: TimelineWindow; +} + +declare module 'view' { + interface ViewContext { + timeline?: TimelineViewContext; + } +} diff --git a/src/components/surround.ts b/src/components/surround.ts index 8b83ced0..91ff9def 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -7,9 +7,11 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { isEqual } from 'lodash-es'; import { CameraManager } from '../camera-manager/manager.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; +import { TimelineKey } from '../components-lib/timeline/types.js'; import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js'; import { CardWideConfig } from '../config/schema/types.js'; @@ -44,7 +46,7 @@ export class AdvancedCameraCardSurround extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _cameraIDsForTimeline?: Set; + protected _keysForTimeline?: TimelineKey[] = []; /** * Determine if a drawer is being used. @@ -72,11 +74,26 @@ export class AdvancedCameraCardSurround extends LitElement { ) || this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode) ) { - this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; + const newKeys = this._getKeysForTimeline(); + // Update only if changed, to avoid unnecessary timeline destructions. + if (!isEqual(newKeys, this._keysForTimeline)) { + this._keysForTimeline = newKeys ?? undefined; + } } } - protected _getCameraIDsForTimeline(): Set | null { + protected _getKeysForTimeline(): TimelineKey[] | null { + const cameraIDsToKeys = (cameraIDs: Set | null): TimelineKey[] => { + const keys: TimelineKey[] = []; + for (const cameraID of cameraIDs ?? []) { + keys.push({ + type: 'camera', + cameraID: cameraID, + }); + } + return keys; + }; + const view = this.viewManagerEpoch?.manager.getView(); if (!view || !this.cameraManager) { return null; @@ -87,20 +104,35 @@ export class AdvancedCameraCardSurround extends LitElement { anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const], }; if (view.supportsMultipleDisplayModes() && view.isGrid()) { - return this.cameraManager - .getStore() - .getCameraIDsWithCapability(capabilitySearch); + return cameraIDsToKeys( + this.cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch), + ); } else { - return this.cameraManager - .getStore() - .getAllDependentCameras(view.camera, capabilitySearch); + return cameraIDsToKeys( + this.cameraManager + .getStore() + .getAllDependentCameras(view.camera, capabilitySearch), + ); } } const queries = view.query; - if (view.isViewerView() && QueryClassifier.isMediaQuery(queries)) { - return queries.getQueryCameraIDs() ?? null; + if (view.isViewerView()) { + if (QueryClassifier.isMediaQuery(queries)) { + return cameraIDsToKeys(queries.getQueryCameraIDs()); + } else if (QueryClassifier.isFolderQuery(queries)) { + const folderConfig = queries.getQuery()?.folder; + return folderConfig + ? [ + { + type: 'folder' as const, + folder: folderConfig, + }, + ] + : []; + } } + return null; } @@ -150,7 +182,7 @@ export class AdvancedCameraCardSurround extends LitElement { this.thumbnailConfig?.mode === 'none' ? 'play' : 'select'} - .cameraIDs=${this._cameraIDsForTimeline} + .keys=${this._keysForTimeline} .mini=${true} .timelineConfig=${this.timelineConfig} .thumbnailConfig=${this.thumbnailConfig} diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 6303de73..f8001263 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1,4 +1,3 @@ -import { add, differenceInSeconds, sub } from 'date-fns'; import { CSSResultGroup, LitElement, @@ -7,111 +6,31 @@ import { html, unsafeCSS, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; -import { isEqual, throttle } from 'lodash-es'; -import { ViewContext } from 'view'; -import { DataSet } from 'vis-data/esnext'; -import type { - DataGroupCollectionType, - DateType, - IdType, - TimelineFormatOption, -} from 'vis-timeline/esnext'; -import { - Timeline, - TimelineEventPropertiesResult, - TimelineItem, - TimelineOptions, - TimelineOptionsCluster, - TimelineWindow, -} from 'vis-timeline/esnext'; +import type { IdType } from 'vis-timeline/esnext'; import { CameraManager } from '../camera-manager/manager'; -import { rangesOverlap } from '../camera-manager/range'; -import { MediaQuery } from '../camera-manager/types'; -import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly'; import { ViewItemManager } from '../card-controller/view/item-manager'; -import { MergeContextViewModifier } from '../card-controller/view/modifiers/merge-context'; import { ViewManagerEpoch } from '../card-controller/view/types'; +import { TimelineController } from '../components-lib/timeline/controller'; import { - AdvancedCameraCardTimelineItem, - TimelineDataSource, -} from '../components-lib/timeline-source'; -import { CameraConfig } from '../config/schema/cameras'; -import { AdvancedCameraCardView } from '../config/schema/common/const'; + ThumbnailDataRequest, + ThumbnailDataRequestEvent, + TimelineItemClickAction, + TimelineKey, +} from '../components-lib/timeline/types'; import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails'; -import { - TimelineCoreConfig, - TimelinePanMode, -} from '../config/schema/common/controls/timeline'; -import { CardWideConfig, configDefaults } from '../config/schema/types'; +import { TimelineCoreConfig } from '../config/schema/common/controls/timeline'; +import { CardWideConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; import timelineCoreStyle from '../scss/timeline-core.scss'; -import { stopEventFromActivatingCardWideActions } from '../utils/action'; -import { - contentsChanged, - formatDateAndTime, - isHoverableDevice, - isTruthy, - setOrRemoveAttribute, -} from '../utils/basic'; -import { findBestMediaTimeIndex } from '../utils/find-best-media-time-index'; -import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event'; -import { ViewMedia } from '../view/item'; -import { ViewItemClassifier } from '../view/item-classifier'; -import { EventMediaQuery, MediaQueries, RecordingMediaQuery } from '../view/query'; -import { QueryClassifier, QueryType } from '../view/query-classifier'; -import { QueryResults } from '../view/query-results'; -import { mergeViewContext } from '../view/view'; +import { contentsChanged } from '../utils/basic'; import './date-picker.js'; import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js'; import './icon'; import './thumbnail/thumbnail.js'; -interface AdvancedCameraCardGroupData { - id: string; - content: string; -} - -interface TimelineRangeChange extends TimelineWindow { - event: Event & { additionalEvent?: string }; - byUser: boolean; -} - -interface TimelineViewContext { - window?: TimelineWindow; -} - -type TimelineItemClickAction = 'play' | 'select'; - -declare module 'view' { - interface ViewContext { - timeline?: TimelineViewContext; - } -} - -interface ExtendedTimeline extends Timeline { - // setCustomTimeMarker currently missing from Timeline types. - setCustomTimeMarker?(time: DateType, id?: IdType): void; -} - -// An event used to fetch data required for thumbnail rendering. See special -// note below on why this is necessary. -interface ThumbnailDataRequest { - item: IdType; - hass?: HomeAssistant; - cameraManager?: CameraManager; - cameraConfig?: CameraConfig; - media?: ViewMedia; - viewManagerEpoch?: ViewManagerEpoch; - viewItemManager?: ViewItemManager; -} - -class ThumbnailDataRequestEvent extends CustomEvent {} - -const TIMELINE_TARGET_BAR_ID = 'target_bar'; - /** * A simple thumbnail wrapper class for use in the timeline where Lit data * bindings are not available. @@ -201,7 +120,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { // Which cameraIDs to include in the timeline. If not specified, all cameraIDs // are shown. @property({ attribute: false, hasChanged: contentsChanged }) - public cameraIDs?: Set; + public keys: TimelineKey[] = []; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -215,76 +134,16 @@ export class AdvancedCameraCardTimelineCore extends LitElement { @property({ attribute: false }) public itemClickAction?: TimelineItemClickAction; - @state() - protected _panMode: TimelinePanMode | null = null; - - protected _targetBarVisible = false; - protected _refDatePicker: Ref = createRef(); protected _refTimeline: Ref = createRef(); - protected _timeline?: ExtendedTimeline; - - protected _timelineSource: TimelineDataSource | null = null; - - // Need a way to separate when a user clicks (to pan the timeline) vs when a - // user clicks (to choose a recording (non-event) to play). - protected _pointerHeld: - | (TimelineEventPropertiesResult & { window?: TimelineWindow }) - | null = null; - protected _ignoreClick = false; - - protected readonly _isHoverableDevice = isHoverableDevice(); - - // Range changes are volumonous: throttle the calls on seeking. - protected _throttledSetViewDuringRangeChange = throttle( - this._setViewDuringRangeChange.bind(this), - 1000 / 10, - ); - - /** - * Get a tooltip for a given timeline event. - * @param item The TimelineItem in question. - * @returns The tooltip as a string to render. - */ - protected _getTooltip(item: TimelineItem): string { - if (!this._isHoverableDevice) { - // Don't display tooltips on touch devices, they just get in the way of - // the drawer. - return ''; - } - - // Cannot use Lit data-bindings as visjs requires a string for tooltips. - // Note that changes to attributes here must be mirrored in the xss - // whitelist in `_getOptions()` . - return ` - - `; - } - - protected _handleThumbnailDataRequest(request: ThumbnailDataRequestEvent): void { - const item = request.detail.item; - const media = this._timelineSource?.dataset.get(item)?.media; - const cameraConfig = media - ? this.cameraManager?.getStore().getCameraConfigForMedia(media) ?? undefined - : undefined; - - request.detail.hass = this.hass; - request.detail.cameraConfig = cameraConfig; - request.detail.cameraManager = this.cameraManager; - request.detail.viewItemManager = this.viewItemManager; - request.detail.media = media; - request.detail.viewManagerEpoch = this.viewManagerEpoch; - } + protected _controller: TimelineController = new TimelineController(this); protected render(): TemplateResult | void { - if (!this.hass || !this.timelineConfig || !this.cameraIDs?.size) { + if (!this.hass || !this.timelineConfig || !this.keys?.length) { return; } - const panMode = this._getEffectivePanMode(); + const panMode = this._controller.getEffectivePanMode(); const panTitle = panMode === 'pan' @@ -304,26 +163,16 @@ export class AdvancedCameraCardTimelineCore extends LitElement { : 'mdi:camera-lock'; return html`
- ${this._shouldSupportSeeking() + ${this._controller.shouldSupportSeeking() ? html` { - this._panMode = - panMode === 'pan' - ? 'seek' - : panMode === 'seek' - ? 'seek-in-media' - : panMode === 'seek-in-media' - ? 'seek-in-camera' - : 'pan'; - }} + @click=${() => this._controller.cyclePanMode()} aria-label="${panTitle}" title="${panTitle}" > @@ -335,7 +184,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { ev: CustomEvent, ) => { if (ev.detail.date) { - this._timeline?.moveTo(ev.detail.date); + this._controller.setTimelineDate(ev.detail.date); } }} > @@ -344,588 +193,6 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
`; } - /** - * Called whenever the range is in the process of being changed. - * @param properties - */ - protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void { - if (this._pointerHeld) { - this._ignoreClick = true; - } - - if ( - this._shouldSupportSeeking() && - this._timeline && - properties.byUser && - // Do not adjust select/seek media during zoom events. - properties.event.type !== 'wheel' && - properties.event.additionalEvent !== 'pinchin' && - properties.event.additionalEvent !== 'pinchout' - ) { - const targetTime = this._pointerHeld?.window - ? add(properties.start, { - seconds: - (this._pointerHeld.time.getTime() - - this._pointerHeld.window.start.getTime()) / - 1000, - }) - : properties.end; - - if (this._pointerHeld) { - this._setTargetBarAppropriately(targetTime); - } - - this._throttledSetViewDuringRangeChange(targetTime, properties); - } - } - - protected _shouldSupportSeeking(): boolean { - return this.mini; - } - - /** - * Set the target bar at a given time. - * @param targetTime - */ - protected _setTargetBarAppropriately(targetTime: Date): void { - if (!this._timeline) { - return; - } - - const view = this.viewManagerEpoch?.manager.getView(); - const panMode = this._getEffectivePanMode(); - const targetBarOn = - this._shouldSupportSeeking() && - (panMode === 'seek' || - ((panMode === 'seek-in-camera' || panMode === 'seek-in-media') && - this._timeline.getSelection().some((id) => { - const item = this._timelineSource?.dataset?.get(id); - return ( - panMode !== 'seek-in-camera' || - item?.media?.getCameraID() === view?.camera, - item && - item.start && - item.end && - targetTime.getTime() >= item.start && - targetTime.getTime() <= item.end - ); - }))); - - if (targetBarOn) { - if (!this._targetBarVisible) { - this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); - this._targetBarVisible = true; - } else { - this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); - } - - const window = this._timeline.getWindow(); - const markerProportion = - (targetTime.getTime() - window.start.getTime()) / - (window.end.getTime() - window.start.getTime()); - - // Position the marker proportionally to how 'far' the pointer is being - // held relative to the timeline window. - this.setAttribute( - 'target-bar-marker-direction', - markerProportion < 0.25 ? 'right' : markerProportion > 0.75 ? 'left' : 'center', - ); - this._timeline?.setCustomTimeMarker?.( - formatDateAndTime(targetTime, true), - TIMELINE_TARGET_BAR_ID, - ); - } else { - this._removeTargetBar(); - } - } - - /** - * Remove the target bar. - */ - protected _removeTargetBar(): void { - this.removeAttribute('target-bar-direction'); - if (this._targetBarVisible) { - this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID); - this._targetBarVisible = false; - } - } - - /** - * Set the view during a range change. - * @param targetTime The target time. - * @param properties The range change properties. - * @returns - */ - protected async _setViewDuringRangeChange( - targetTime: Date, - properties: TimelineRangeChange, - ): Promise { - const view = this.viewManagerEpoch?.manager.getView(); - const results = view?.queryResults; - const media = results?.getResults(); - const panMode = this._getEffectivePanMode(); - if ( - !media || - !results || - !this._timeline || - !view || - !this.hass || - !this.cameraManager || - panMode === 'pan' - ) { - return; - } - - const canSeek = this._shouldSupportSeeking(); - let newResults: QueryResults | null = null; - - if (panMode === 'seek') { - newResults = results - .clone() - .selectBestResult( - (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime, view?.camera), - { - allCameras: true, - main: true, - }, - ); - } else if (panMode === 'seek-in-camera') { - newResults = results - .clone() - .selectBestResult( - (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime), - { - cameraID: view.camera, - }, - ) - .promoteCameraSelectionToMainSelection(view.camera); - } else if (panMode === 'seek-in-media') { - newResults = results; - } - - const desiredView: AdvancedCameraCardView = this.mini - ? targetTime >= new Date() - ? 'live' - : 'media' - : view.view; - - const selectedItem = newResults?.getSelectedResult(); - const selectedCamera = ViewItemClassifier.isMedia(selectedItem) - ? selectedItem.getCameraID() - : null; - - this.viewManagerEpoch?.manager.setViewByParameters({ - params: { - ...(selectedCamera && { camera: selectedCamera }), - view: desiredView, - queryResults: newResults, - }, - modifiers: [ - new MergeContextViewModifier({ - ...(canSeek && { mediaViewer: { seek: targetTime } }), - ...this._getTimelineContext({ start: properties.start, end: properties.end }), - }), - ], - }); - } - - protected _getEffectivePanMode(): TimelinePanMode { - return this._panMode ?? this.timelineConfig?.pan_mode ?? 'pan'; - } - - /** - * Called whenever the timeline is clicked. - * @param properties The properties of the timeline click event. - */ - protected async _timelineClickHandler( - properties: TimelineEventPropertiesResult, - ): Promise { - // Calls to stopEventFromActivatingCardWideActions() are included for - // completeness. Timeline does not support card-wide events and they are - // disabled in card.ts in `_getMergedActions`. - if ( - this._ignoreClick || - (properties.what && - ['item', 'background', 'group-label', 'axis'].includes(properties.what)) - ) { - stopEventFromActivatingCardWideActions(properties.event); - } - - const view = this.viewManagerEpoch?.manager.getView(); - - if ( - this._ignoreClick || - !view || - !this.viewManagerEpoch || - !this._timelineSource || - !properties.what - ) { - return; - } - - let drawerAction: 'open' | 'close' = 'close'; - - if ( - this.timelineConfig?.show_recordings && - properties.time && - ['background', 'axis'].includes(properties.what) - ) { - const query = this._createMediaQueries('recording'); - if (query) { - await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - baseView: view, - params: { view: 'recording', query: query }, - queryExecutorOptions: { - selectResult: { - time: { - time: properties.time, - }, - }, - }, - }); - } - } else if (properties.item && properties.what === 'item') { - const cameraID = String(properties.group); - const id = String(properties.item); - - const criteria = { - main: true, - ...(cameraID && view.isGrid() && { cameraID: cameraID }), - }; - const newResults = view.queryResults - ?.clone() - .resetSelectedResult() - .selectResultIfFound((media) => media.getID() === properties.item, criteria); - - const context: ViewContext = mergeViewContext(this._getTimelineContext(), { - mediaViewer: { seek: properties.time }, - }); - - if (!newResults || !newResults.hasSelectedResult()) { - // This can happen in a few situations: - // - If this is a recording query (with recorded hours) and an event is - // clicked on the timeline - // - If the current thumbnails/results is a filtered view from the media - // gallery (i.e. any case where the thumbnails may not be match the - // events on the timeline, e.g. in the snapshots viewer but - // mini-timeline showing all media). - const query = this._createMediaQueries('event'); - if (query) { - await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - params: { view: 'media', query: query }, - queryExecutorOptions: { - selectResult: { - id: id, - }, - rejectResults: (results) => !results.hasResults(), - }, - modifiers: [new MergeContextViewModifier(context)], - }); - } - } else { - this.viewManagerEpoch.manager.setViewByParameters({ - params: { - queryResults: newResults, - view: this.itemClickAction === 'play' ? 'media' : view.view, - }, - modifiers: [new MergeContextViewModifier(context)], - }); - } - - if (this.itemClickAction === 'select') { - drawerAction = 'open'; - } - } - - fireAdvancedCameraCardEvent(this, `thumbnails:${drawerAction}`); - - this._ignoreClick = false; - } - - /** - * Get a broader prefetch window from a start and end basis. - * @param window The window to broaden. - * @returns A broader timeline. - */ - protected _getPrefetchWindow(window: TimelineWindow): TimelineWindow { - const delta = differenceInSeconds(window.end, window.start); - return { - start: sub(window.start, { seconds: delta }), - end: add(window.end, { seconds: delta }), - }; - } - - /** - * Handle a range change in the timeline. - * @param properties vis.js provided range information. - */ - protected async _timelineRangeChangedHandler(properties: { - start: Date; - end: Date; - byUser: boolean; - event: Event & { additionalEvent: string }; - }): Promise { - this._removeTargetBar(); - const view = this.viewManagerEpoch?.manager.getView(); - - if ( - !this._timeline || - !view || - // When in mini mode, something else is in charge of the primary media - // population (e.g. the live view), in this case only act when the user - // themselves are interacting with the timeline. - (this.mini && !properties.byUser) - ) { - return; - } - - await this._timelineSource?.refresh(this._getPrefetchWindow(properties)); - - const queryType = QueryClassifier.getQueryType(view.query); - if (!queryType) { - return; - } - const mediaQuery = this._createMediaQueries(queryType); - if (!mediaQuery || this._alreadyHasAcceptableMediaQuery(mediaQuery)) { - return; - } - - await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - params: { - query: mediaQuery, - }, - queryExecutorOptions: { - selectResult: { - id: - this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.getSelectedResult() - ?.getID() ?? undefined, - }, - }, - modifiers: [new MergeContextViewModifier(this._getTimelineContext())], - }); - } - - protected _createMediaQueries( - type: QueryType, - options?: { - window?: TimelineWindow; - }, - ): MediaQueries | null { - if (!this._timeline || !this._timelineSource) { - return null; - } - - const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes( - this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()), - ); - - if (type === 'event') { - const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); - return queries ? new EventMediaQuery(queries) : null; - } else if (type === 'recording') { - const queries = - this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow); - return queries ? new RecordingMediaQuery(queries) : null; - } - return null; - } - - /** - * Build the visjs dataset to render on the timeline. - * @returns The dataset. - */ - protected _getGroups(): DataGroupCollectionType { - const groups: AdvancedCameraCardGroupData[] = []; - (this.cameraIDs ?? []).forEach((cameraID: string) => { - if (!this.hass || !this.cameraManager) { - return; - } - const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); - - if (cameraMetadata) { - groups.push({ - id: cameraID, - content: cameraMetadata.title, - }); - } - }); - return new DataSet(groups); - } - - protected _getPerfectWindowFromMediaStartAndEndTime( - isEvent: boolean, - startTime: Date | null, - endTime: Date | null, - ): TimelineWindow | null { - if (isEvent) { - const windowSeconds = this._getConfiguredWindowSeconds(); - - if (startTime && endTime) { - if (endTime.getTime() - startTime.getTime() > windowSeconds * 1000) { - // If the event is larger than the configured window, only show the most - // recent portion of the event that fits in the window. - return { - start: sub(endTime, { seconds: windowSeconds }), - end: endTime, - }; - } else { - // If the event is shorter than the configured window, center the event - // in the window. - const gap = windowSeconds - (endTime.getTime() - startTime.getTime()) / 1000; - return { - start: sub(startTime, { seconds: gap / 2 }), - end: add(endTime, { seconds: gap / 2 }), - }; - } - } else if (startTime) { - // If there's no end-time yet, place the start-time in the center of the - // time window. - return { - start: sub(startTime, { seconds: windowSeconds / 2 }), - end: add(startTime, { seconds: windowSeconds / 2 }), - }; - } - } else if (startTime && endTime) { - return { - start: startTime, - end: endTime, - }; - } - return null; - } - - /** - * Get the configured window length in seconds. - */ - protected _getConfiguredWindowSeconds(): number { - return this.timelineConfig?.window_seconds ?? configDefaults.timeline.window_seconds; - } - - /** - * Get desired timeline start/end time. - * @returns A tuple of start/end date. - */ - protected _getDefaultStartEnd(): TimelineWindow { - const end = new Date(); - const start = sub(end, { - seconds: this._getConfiguredWindowSeconds(), - }); - return { start: start, end: end }; - } - - /** - * Determine if the timeline should use clustering. - * @returns `true` if the timeline should cluster, `false` otherwise. - */ - protected _isClustering(): boolean { - return ( - this.timelineConfig?.style === 'stack' && - !!this.timelineConfig?.clustering_threshold && - this.timelineConfig.clustering_threshold > 0 - ); - } - - protected _getDateTimeFormat(): TimelineFormatOption { - const format24Hour = !!this.timelineConfig?.format?.['24h']; - - // See: https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options - return { - minorLabels: { - minute: format24Hour ? 'HH:mm' : 'h:mm A', - hour: format24Hour ? 'HH:mm' : 'h:mm A', - }, - majorLabels: { - millisecond: format24Hour ? 'HH:mm:ss' : 'h:mm:ss A', - second: format24Hour ? 'D MMMM HH:mm' : 'D MMMM h:mm A', - }, - }; - } - - /** - * Get timeline options. - */ - protected _getOptions(): TimelineOptions | null { - if (!this.timelineConfig) { - return null; - } - - const defaultWindow = this._getDefaultStartEnd(); - const stack = this.timelineConfig.style === 'stack'; - // Configuration for the Timeline, see: - // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options - return { - cluster: this._isClustering() - ? { - // It would be better to automatically calculate `maxItems` from the - // rendered height of the timeline (or group within the timeline) so - // as to not waste vertical space (e.g. after the user changes to - // fullscreen mode). Unfortunately this is not easy to do, as we - // don't know the height of the timeline until after it renders -- - // and if we adjust `maxItems` then we can get into an infinite - // resize loop. Adjusting the `maxItems` of a timeline, after it's - // created, also does not appear to work as expected. - maxItems: this.timelineConfig.clustering_threshold, - - clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { - const selectedIDs = this._getAllSelectedMediaIDsFromView(); - const firstMedia = (first).media; - const secondMedia = (second).media; - - // Never include the currently selected item in a cluster, and - // never group different object types together (e.g. person and - // car). - return ( - first.type !== 'background' && - first.type === second.type && - !selectedIDs.includes(first.id) && - !selectedIDs.includes(second.id) && - !!firstMedia && - !!secondMedia && - ViewItemClassifier.isEvent(firstMedia) && - ViewItemClassifier.isEvent(secondMedia) && - firstMedia.isGroupableWith(secondMedia) - ); - }, - } - : // Timeline type information is incorrect requiring this 'as'. - (false as unknown as TimelineOptionsCluster), - minHeight: '100%', - maxHeight: '100%', - zoomMax: 1 * 24 * 60 * 60 * 1000, - zoomMin: 1 * 1000, - margin: { - item: { - // In ribbon mode, a 20px item is reduced to 6px, so need to add a - // 14px margin to ensure items line up with subgroups. - vertical: stack ? 10 : 24, - }, - }, - selectable: true, - stack: stack, - start: defaultWindow.start, - end: defaultWindow.end, - groupHeightMode: 'auto', - tooltip: { - followMouse: true, - overflowMethod: 'cap', - template: this._getTooltip.bind(this), - }, - format: this._getDateTimeFormat(), - xss: { - disabled: false, - filterOptions: { - whiteList: { - 'advanced-camera-card-timeline-thumbnail': ['details', 'item'], - div: ['title'], - span: ['style'], - }, - }, - }, - }; - } - /** * Determine if the component should be updated. * @param _changedProps The changed properties. @@ -936,323 +203,49 @@ export class AdvancedCameraCardTimelineCore extends LitElement { return !!this.hass && !!this.cameraManager; } - protected _getAllSelectedMediaIDsFromView(): IdType[] { - const view = this.viewManagerEpoch?.manager.getView(); - return ( - view?.queryResults?.getMultipleSelectedResults({ - main: true, - ...(view.isGrid() && { allCameras: true }), - }) ?? [] - ) - .filter((media) => ViewItemClassifier.isEvent(media)) - .map((media) => media.getID()) - .filter(isTruthy); - } - - /** - * Update the timeline from the view object. - */ - protected async _updateTimelineFromView(): Promise { - const view = this.viewManagerEpoch?.manager.getView(); - if (!view || !this.timelineConfig || !this._timelineSource || !this._timeline) { - return; - } - - const timelineWindow = this._timeline.getWindow(); - - // Calculate the timeline window to show. If there is a window set in the - // view context, always honor that. Otherwise, if there's a selected media - // item that is already within the current window (even if it's not - // perfectly positioned) -- leave it as is. Otherwise, change the window to - // perfectly center on the media. - - let desiredWindow = timelineWindow; - const item = view.queryResults?.getSelectedResult(); - const media = item && ViewItemClassifier.isMedia(item) ? item : null; - const mediaStartTime = media?.getStartTime() ?? null; - const mediaEndTime = media?.getEndTime() ?? null; - const mediaIsEvent = media ? ViewItemClassifier.isEvent(media) : false; - - const mediaWindow: TimelineWindow | null = - media && mediaStartTime - ? // If this media has no end time, it's just a "point" in time so the - // range effectively starts/ends at the same time. - { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime } - : null; - const context = view.context?.timeline; - - if (context && context.window) { - desiredWindow = context.window; - } else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { - const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime( - mediaIsEvent, - mediaStartTime, - mediaEndTime, - ); - if (perfectMediaWindow) { - desiredWindow = perfectMediaWindow; - } - } - const prefetchedWindow = this._getPrefetchWindow(desiredWindow); - - if (!this._pointerHeld) { - // Don't fetch any data or touch the timeline in any way if the user is - // currently interacting with it. Without this the subsequent data fetches - // (via fetchIfNecessary) may update the timeline contents which causes - // the visjs timeline to stop dragging/panning operations which is very - // disruptive to the user. - await this._timelineSource?.refresh(prefetchedWindow); - } - - const currentSelection = this._timeline.getSelection(); - const mediaIDsToSelect = this._getAllSelectedMediaIDsFromView(); - - const needToSelect = mediaIDsToSelect.some( - (mediaID) => !currentSelection.includes(mediaID), - ); - - if (needToSelect) { - if (this._isClustering()) { - // Hack: Clustering may not update unless the dataset changes, artifically - // update the dataset to ensure the newly selected item cannot be included - // in a cluster. - - for (const mediaID of mediaIDsToSelect) { - // Need to this rewrite prior to setting the selection (just below), or - // the selection will be lost on rewrite. - this._timelineSource?.rewriteEvent(mediaID); - } - } - - this._timeline?.setSelection(mediaIDsToSelect, { - focus: false, - animation: { - animation: false, - zoom: false, - }, - }); - } - - // Set the timeline window if necessary. - if (!this._pointerHeld && !isEqual(desiredWindow, timelineWindow)) { - this._timeline.setWindow(desiredWindow.start, desiredWindow.end); - } - - // Only generate thumbnails if the existing query is not an acceptable - // match, to avoid getting stuck in a loop (the subsequent fetches will not - // actually fetch since the data will have been cached). - // - // Timeline receives a new `view` - // -> Events fetched - // -> Thumbnails generated - // -> New view dispatched (to load thumbnails into outer carousel). - // -> New view received ... [loop] - // - // Also don't generate thumbnails in mini-timelines (they will already have - // been generated). - - const queryType = QueryClassifier.getQueryType(view.query); - if (!queryType) { - return; - } - - const freshMediaQuery = this._createMediaQueries(queryType, { - window: desiredWindow, - }); - - if ( - !this.mini && - freshMediaQuery && - !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) - ) { - const currentlySelectedResult = this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.getSelectedResult(); - - await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - params: { - query: freshMediaQuery, - }, - queryExecutorOptions: { - selectResult: { - id: currentlySelectedResult?.getID() ?? undefined, - }, - }, - modifiers: [ - new MergeContextViewModifier(this._getTimelineContext(desiredWindow)), - ], - }); - } - } - - protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { - const view = this.viewManagerEpoch?.manager.getView(); - const query = view?.query; - - if (!this.cameraManager || !query || !QueryClassifier.isMediaQuery(query)) { - return false; - } - - const currentQueries = query?.getQuery(); - const currentResultTimestamp = view?.queryResults?.getResultsTimestamp(); - - return ( - !!currentQueries && - !!currentResultTimestamp && - !!query?.isSupersetOf(freshMediaQuery) && - this.cameraManager.areMediaQueriesResultsFresh( - currentQueries, - currentResultTimestamp, - ) - ); - } - - /** - * Generate the context for timeline views. - * @returns The TimelineViewContext object. - */ - protected _getTimelineContext(window?: TimelineWindow): ViewContext { - const view = this.viewManagerEpoch?.manager.getView(); - const newWindow = window ?? this._timeline?.getWindow(); - return { - timeline: { - ...view?.context?.timeline, - ...(newWindow && { window: newWindow }), - }, - }; - } - /** * Called when an update will occur. * @param changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('thumbnailConfig')) { - if (this.thumbnailConfig) { - this.style.setProperty( - '--advanced-camera-card-thumbnail-size', - `${this.thumbnailConfig.size}px`, - ); - } else { - this.style.removeProperty('--advanced-camera-card-thumbnail-size'); - } - } - - if (changedProps.has('timelineConfig')) { - setOrRemoveAttribute(this, !!this.timelineConfig?.show_recordings, 'recordings'); - setOrRemoveAttribute(this, this.timelineConfig?.style === 'ribbon', 'ribbon'); - setOrRemoveAttribute(this, this.timelineConfig?.style === 'stack', 'stack'); + if (changedProps.has('hass')) { + this._controller.setHass(this.hass ?? null); } if ( - changedProps.has('cameraManager') || - changedProps.has('cameras') || - changedProps.has('timelineConfig') || - changedProps.has('cameraIDs') + [ + 'cameraManager', + 'viewItemManager', + 'viewManagerEpoch', + 'timelineConfig', + 'mini', + 'thumbnailConfig', + 'keys', + ].some((prop) => changedProps.has(prop)) ) { - if (this.cameraIDs?.size && this.cameraManager && this.timelineConfig) { - this._timelineSource = new TimelineDataSource( - this.cameraManager, - this.cameraIDs, - this.timelineConfig.events_media_type, - this.timelineConfig.show_recordings, - ); - } else { - this._timelineSource = null; - } + this._controller.setOptions({ + cameraManager: this.cameraManager, + viewItemManager: this.viewItemManager, + timelineConfig: this.timelineConfig, + mini: this.mini, + thumbnailConfig: this.thumbnailConfig, + keys: this.keys ?? [], + }); } } - /** - * Destroy/reset the timeline. - */ - protected _destroy(): void { - this._timeline?.destroy(); - this._timeline = undefined; - this._targetBarVisible = false; - this._pointerHeld = null; - } - - /** - * Called when the component is updated. - * @param changedProperties The changed properties if any. - */ protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - - if (changedProperties.has('cameras') || changedProperties.has('cameraManager')) { - this._destroy(); - } - - let createdTimeline = false; - - if ( - this._timelineSource && - this._refTimeline.value && - this.timelineConfig && - (!this._timeline || - changedProperties.has('timelineConfig') || - changedProperties.has('cameraIDs')) - ) { - if (this._timeline) { - this._destroy(); - } - - const groups = this._getGroups(); - if (!groups.length) { - return; - } - - const options = this._getOptions(); - if (options) { - createdTimeline = true; - const noGroups = this.mini && groups.length === 1; - if (noGroups) { - this._timeline = new Timeline( - this._refTimeline.value, - this._timelineSource.dataset, - options, - ) as Timeline; - } else { - this._timeline = new Timeline( - this._refTimeline.value, - this._timelineSource.dataset, - groups, - options, - ) as Timeline; - } - setOrRemoveAttribute(this, !noGroups, 'groups'); - - this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); - this._timeline.on('click', this._timelineClickHandler.bind(this)); - this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); - - // This complexity exists to ensure we can tell between a click that - // causes the timeline zoom/range to change, and a 'static' click on the - // // timeline (which may need to trigger a card wide event). - this._timeline.on('mouseDown', (ev: TimelineEventPropertiesResult) => { - const window = this._timeline?.getWindow(); - this._pointerHeld = { - ...ev, - ...(window && { window: window }), - }; - this._ignoreClick = false; - }); - this._timeline.on('mouseUp', () => { - this._pointerHeld = null; - this._removeTargetBar(); - }); - } - } - - if (createdTimeline) { + if (this._controller.setTimelineElement(this._refTimeline.value)) { // 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 if (changedProperties.has('viewManagerEpoch')) { - this._updateTimelineFromView(); + window.requestAnimationFrame(() => + this._controller.setView(this.viewManagerEpoch ?? null), + ); + } else { + this._controller.setView(this.viewManagerEpoch ?? null); } } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index b821147d..d24653b3 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -3,6 +3,7 @@ import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager'; import { ViewItemManager } from '../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; +import { TimelineKey } from '../components-lib/timeline/types'; import { TimelineConfig } from '../config/schema/timeline'; import { CardWideConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; @@ -30,6 +31,16 @@ export class AdvancedCameraCardTimeline extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; + protected _getKeys(): TimelineKey[] { + const keys: TimelineKey[] = []; + for (const camera of this.cameraManager?.getStore().getCameraIDsWithCapability({ + anyCapabilities: ['clips', 'snapshots', 'recordings'], + }) ?? []) { + keys.push({ type: 'camera', cameraID: camera }); + } + return keys; + } + protected render(): TemplateResult | void { if (!this.timelineConfig) { return html``; @@ -43,9 +54,7 @@ export class AdvancedCameraCardTimeline extends LitElement { .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameraManager=${this.cameraManager} .viewItemManager=${this.viewItemManager} - .cameraIDs=${this.cameraManager?.getStore().getCameraIDsWithCapability({ - anyCapabilities: ['clips', 'snapshots', 'recordings'], - })} + .keys=${this._getKeys()} .cardWideConfig=${this.cardWideConfig} .itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none' ? 'play' diff --git a/src/config/schema/actions/custom/folders-view.ts b/src/config/schema/actions/custom/folders-view.ts deleted file mode 100644 index 79ed0fb5..00000000 --- a/src/config/schema/actions/custom/folders-view.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; -import { advancedCameraCardCustomActionsBaseSchema } from './base'; - -export const foldersViewActionConfigSchema = - advancedCameraCardCustomActionsBaseSchema.extend({ - advanced_camera_card_action: z.literal('folders').or(z.literal('folder')), - folder: z.string().optional(), - }); -export type FoldersViewActionConfig = z.infer; diff --git a/src/config/schema/actions/custom/view.ts b/src/config/schema/actions/custom/view.ts index 8ca57ce1..dafaa817 100644 --- a/src/config/schema/actions/custom/view.ts +++ b/src/config/schema/actions/custom/view.ts @@ -1,23 +1,9 @@ import { z } from 'zod'; -import { - AdvancedCameraCardUserSpecifiedView, - VIEWS_USER_SPECIFIED, -} from '../../common/const'; +import { VIEWS_USER_SPECIFIED } from '../../common/const'; import { advancedCameraCardCustomActionsBaseSchema } from './base'; -type AdvancedCameraCardUserSpecifiedViewWithoutFolder = Exclude< - AdvancedCameraCardUserSpecifiedView, - 'folder' | 'folders' ->; - export const viewActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({ - advanced_camera_card_action: z.enum( - // The folder/folders views are handled separately as they accept an - // optional folder ID. - VIEWS_USER_SPECIFIED.filter((view) => view !== 'folder' && view !== 'folders') as [ - AdvancedCameraCardUserSpecifiedViewWithoutFolder, - ...AdvancedCameraCardUserSpecifiedViewWithoutFolder[], - ], - ), + advanced_camera_card_action: z.enum(VIEWS_USER_SPECIFIED), + folder: z.string().optional(), }); export type ViewActionConfig = z.infer; diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index dfb266a8..5ac4464d 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -3,7 +3,6 @@ import { statusBarItemBaseSchema } from '../common/status-bar'; import { advancedCameraCardCustomActionsBaseSchema } from './custom/base'; import { cameraSelectActionConfigSchema } from './custom/camera-select'; import { viewDisplayModeActionConfigSchema } from './custom/display-mode'; -import { foldersViewActionConfigSchema } from './custom/folders-view'; import { generalActionConfigSchema } from './custom/general'; import { internalCallbackActionConfigSchema } from './custom/internal'; import { logActionConfigSchema } from './custom/log'; @@ -42,7 +41,6 @@ export const statusBarActionConfigSchema: z.ZodSchema< const advancedCameraCardCustomActionSchema = z.union([ cameraSelectActionConfigSchema, - foldersViewActionConfigSchema, generalActionConfigSchema, internalCallbackActionConfigSchema, logActionConfigSchema, diff --git a/src/config/schema/folders.ts b/src/config/schema/folders.ts index b23a27ba..7023d432 100644 --- a/src/config/schema/folders.ts +++ b/src/config/schema/folders.ts @@ -131,6 +131,8 @@ const folderConfigSchema = z.object({ title: z.string().optional(), icon: z.string().optional(), }); -export type FolderConfig = z.infer; +export type FolderConfigWithoutID = z.infer; + +export type FolderConfig = FolderConfigWithoutID & { id: string }; export const foldersConfigSchema = folderConfigSchema.array(); diff --git a/src/utils/action.ts b/src/utils/action.ts index 97e2ab65..3fc84368 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -2,7 +2,6 @@ import { CardActionsAPI } from '../card-controller/types.js'; import { ZoomSettingsBase } from '../components-lib/zoom/types.js'; import { CameraSelectActionConfig } from '../config/schema/actions/custom/camera-select.js'; import { DisplayModeActionConfig } from '../config/schema/actions/custom/display-mode.js'; -import { FoldersViewActionConfig } from '../config/schema/actions/custom/folders-view.js'; import { AdvancedCameraCardGeneralAction, GeneralActionConfig, @@ -47,15 +46,17 @@ export function createGeneralAction( } export function createViewAction( - action: Exclude, + action: AdvancedCameraCardUserSpecifiedView, options?: { cardID?: string; + folderID?: string; }, ): ViewActionConfig { return { action: 'fire-dom-event', advanced_camera_card_action: action, ...(options?.cardID && { card_id: options.cardID }), + ...(options?.folderID && { folder: options.folderID }), }; } @@ -74,21 +75,6 @@ export function createCameraAction( }; } -export function createFoldersViewAction( - view: 'folder' | 'folders', - options?: { - cardID?: string; - folderID?: string; - }, -): FoldersViewActionConfig { - return { - action: 'fire-dom-event', - advanced_camera_card_action: view, - ...(options?.folderID && { folder: options.folderID }), - ...(options?.cardID && { card_id: options.cardID }), - }; -} - export function createMediaPlayerAction( mediaPlayer: string, mediaPlayerAction: 'play' | 'stop', diff --git a/tests/card-controller/actions/actions/folder.test.ts b/tests/card-controller/actions/actions/folder.test.ts deleted file mode 100644 index 22c90a1d..00000000 --- a/tests/card-controller/actions/actions/folder.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { FoldersViewAction } from '../../../../src/card-controller/actions/actions/folders-view'; -import { FolderQuery } from '../../../../src/card-controller/folders/types'; -import { FolderViewQuery } from '../../../../src/view/query'; -import { createCardAPI, createFolder } from '../../../test-utils'; - -describe('should handle folder action', async () => { - it('should handle folder action successfully', async () => { - const api = createCardAPI(); - const action = new FoldersViewAction( - {}, - { - action: 'fire-dom-event', - advanced_camera_card_action: 'folder', - }, - ); - - const folder = createFolder(); - vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder); - - const query: FolderQuery = { - folder, - path: [{ ha: { id: 'path' } }], - }; - vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query); - - await action.execute(api); - - expect( - api.getViewManager().setViewByParametersWithExistingQuery, - ).toHaveBeenCalledWith({ - params: { - view: 'folder', - query: expect.any(FolderViewQuery), - }, - }); - - expect( - vi - .mocked(api.getViewManager().setViewByParametersWithExistingQuery) - .mock.calls[0][0]?.params?.query?.getQuery(), - ).toBe(query); - }); - - it('should do nothing with non-existent folder', async () => { - const api = createCardAPI(); - const action = new FoldersViewAction( - {}, - { - action: 'fire-dom-event', - advanced_camera_card_action: 'folder', - folder: 'NON-EXISTENT-FOLDER', - }, - ); - - vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(null); - - await action.execute(api); - - expect( - api.getViewManager().setViewByParametersWithExistingQuery, - ).not.toHaveBeenCalled(); - }); - - it('should do nothing with non-existent default query', async () => { - const api = createCardAPI(); - const action = new FoldersViewAction( - {}, - { - action: 'fire-dom-event', - advanced_camera_card_action: 'folder', - }, - ); - - const folder = createFolder(); - vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder); - - vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(null); - - await action.execute(api); - - expect( - api.getViewManager().setViewByParametersWithExistingQuery, - ).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/card-controller/actions/actions/status-bar.test.ts b/tests/card-controller/actions/actions/status-bar.test.ts index a631229c..69beb7d7 100644 --- a/tests/card-controller/actions/actions/status-bar.test.ts +++ b/tests/card-controller/actions/actions/status-bar.test.ts @@ -22,7 +22,7 @@ describe('should handle status bar action', () => { it('add', async () => { const api = createCardAPI(); const item = { - type: 'custom:advanced-camera-card-status-bar-string', + type: 'custom:advanced-camera-card-status-bar-string' as const, string: 'Item', }; @@ -44,7 +44,7 @@ describe('should handle status bar action', () => { it('remove', async () => { const api = createCardAPI(); const item = { - type: 'custom:advanced-camera-card-status-bar-string', + type: 'custom:advanced-camera-card-status-bar-string' as const, string: 'Item', }; diff --git a/tests/card-controller/actions/actions/view.test.ts b/tests/card-controller/actions/actions/view.test.ts index 2e290b5d..a5fc0a9c 100644 --- a/tests/card-controller/actions/actions/view.test.ts +++ b/tests/card-controller/actions/actions/view.test.ts @@ -36,3 +36,31 @@ describe('should handle view action', () => { ); }); }); + +describe('should handle folder view action', () => { + it.each([['folder' as const], ['folders' as const]])('%s', async (viewName) => { + const api = createCardAPI(); + + const action = new ViewAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: viewName, + folder: 'folder', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith( + expect.objectContaining({ + params: { + view: viewName, + }, + queryExecutorOptions: { + folder: 'folder', + }, + }), + ); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 869203d2..3703e944 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -7,7 +7,6 @@ import { DefaultAction } from '../../../src/card-controller/actions/actions/defa import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select'; import { DownloadAction } from '../../../src/card-controller/actions/actions/download'; import { ExpandAction } from '../../../src/card-controller/actions/actions/expand'; -import { FoldersViewAction } from '../../../src/card-controller/actions/actions/folders-view'; import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen'; import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback'; import { LogAction } from '../../../src/card-controller/actions/actions/log'; @@ -97,6 +96,8 @@ describe('ActionFactory', () => { ], [{ advanced_camera_card_action: 'download' as const }, DownloadAction], [{ advanced_camera_card_action: 'expand' as const }, ExpandAction], + [{ advanced_camera_card_action: 'folder' as const }, ViewAction], + [{ advanced_camera_card_action: 'folders' as const }, ViewAction], [{ advanced_camera_card_action: 'fullscreen' as const }, FullscreenAction], [{ advanced_camera_card_action: 'image' as const }, ViewAction], [ @@ -185,8 +186,6 @@ describe('ActionFactory', () => { }, InternalCallbackAction, ], - [{ advanced_camera_card_action: 'folder' as const }, FoldersViewAction], - [{ advanced_camera_card_action: 'folders' as const }, FoldersViewAction], ])( 'advanced_camera_card_action: $advanced_camera_card_action', (action: Partial, classObject: object) => { diff --git a/tests/card-controller/folders/manager.test.ts b/tests/card-controller/folders/manager.test.ts index 8b99581d..4c6bd332 100644 --- a/tests/card-controller/folders/manager.test.ts +++ b/tests/card-controller/folders/manager.test.ts @@ -3,7 +3,7 @@ import { mock } from 'vitest-mock-extended'; import { FoldersExecutor } from '../../../src/card-controller/folders/executor'; import { FoldersManager } from '../../../src/card-controller/folders/manager'; import { FolderQuery } from '../../../src/card-controller/folders/types'; -import { FolderConfig } from '../../../src/config/schema/folders'; +import { FolderConfig, FolderConfigWithoutID } from '../../../src/config/schema/folders'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { Endpoint } from '../../../src/types'; import { ViewItemCapabilities } from '../../../src/view/types'; @@ -47,7 +47,13 @@ describe('FoldersManager', () => { it('should add a folder without an id', () => { const manager = new FoldersManager(createCardAPI()); - const folder = createFolder({ title: 'Title' }); + const folder: FolderConfigWithoutID = { + type: 'ha' as const, + title: 'Title', + ha: { + path: [{ id: 'media-source://' }], + }, + }; manager.addFolders([folder]); expect(manager.getFolderCount()).toBe(1); diff --git a/tests/card-controller/view/query-executor.test.ts b/tests/card-controller/view/query-executor.test.ts index 93139a67..d65ef1fa 100644 --- a/tests/card-controller/view/query-executor.test.ts +++ b/tests/card-controller/view/query-executor.test.ts @@ -382,15 +382,15 @@ describe('executeQuery', () => { vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null); const executor = new QueryExecutor(api); - expect(await executor.executeDefaultFolderQuery()).toBeNull(); + expect(await executor.executeFolderQuery()).toBeNull(); }); }); }); -describe('executeDefaultFolderQuery', () => { +describe('executeFolderQuery', () => { it('should return null without folders', async () => { const executor = new QueryExecutor(createCardAPI()); - expect(await executor.executeDefaultFolderQuery()).toBeNull(); + expect(await executor.executeFolderQuery()).toBeNull(); }); it('should execute query against first folder', async () => { @@ -399,13 +399,14 @@ describe('executeDefaultFolderQuery', () => { const folder = createFolder(); const query: FolderQuery = { folder, - path: ['path'], + path: [{ ha: { id: 'path' } }], }; + vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder); vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query); vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(items); const executor = new QueryExecutor(api); - const result = await executor.executeDefaultFolderQuery(); + const result = await executor.executeFolderQuery(); expect(result?.query.getQuery()).toEqual(query); expect(result?.queryResults.getResults()).toEqual(items); @@ -416,12 +417,13 @@ describe('executeDefaultFolderQuery', () => { const folder = createFolder(); const query: FolderQuery = { folder, - path: ['path'], + path: [{ ha: { id: 'path' } }], }; + vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder); vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query); vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null); const executor = new QueryExecutor(api); - expect(await executor.executeDefaultFolderQuery()).toBeNull(); + expect(await executor.executeFolderQuery()).toBeNull(); }); }); diff --git a/tests/card-controller/view/view-query-executor.test.ts b/tests/card-controller/view/view-query-executor.test.ts index 9572e717..48b7332f 100644 --- a/tests/card-controller/view/view-query-executor.test.ts +++ b/tests/card-controller/view/view-query-executor.test.ts @@ -109,7 +109,7 @@ describe('ViewQueryExecutor', () => { }, }); expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled(); + expect(executor.executeFolderQuery).not.toHaveBeenCalled(); }); it('should set query and queryResults for recordings', async () => { @@ -152,7 +152,7 @@ describe('ViewQueryExecutor', () => { }, }); expect(executor.executeDefaultEventQuery).not.toBeCalled(); - expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled(); + expect(executor.executeFolderQuery).not.toHaveBeenCalled(); }); describe('should set timeline window', async () => { @@ -230,7 +230,7 @@ describe('ViewQueryExecutor', () => { expect(view?.queryResults).toBeNull(); expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled(); expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled(); + expect(executor.executeFolderQuery).not.toHaveBeenCalled(); }); }); @@ -264,7 +264,7 @@ describe('ViewQueryExecutor', () => { }, }); expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled(); + expect(executor.executeFolderQuery).not.toHaveBeenCalled(); }); }); @@ -311,7 +311,7 @@ describe('ViewQueryExecutor', () => { }, }); expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled(); - expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled(); + expect(executor.executeFolderQuery).not.toHaveBeenCalled(); }, ); }); @@ -350,7 +350,7 @@ describe('ViewQueryExecutor', () => { useCache: false, }, }); - expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled(); + expect(executor.executeFolderQuery).not.toHaveBeenCalled(); }, ); }); @@ -361,7 +361,7 @@ describe('ViewQueryExecutor', () => { const query = new FolderViewQuery(); const queryResults = new QueryResults(); - executor.executeDefaultFolderQuery.mockResolvedValue({ + executor.executeFolderQuery.mockResolvedValue({ query: query, queryResults: queryResults, }); @@ -376,14 +376,14 @@ describe('ViewQueryExecutor', () => { expect(view?.queryResults).toBe(queryResults); expect(executor.executeDefaultEventQuery).not.toBeCalled(); expect(executor.executeDefaultRecordingQuery).not.toBeCalled(); - expect(executor.executeDefaultFolderQuery).toBeCalledWith({ + expect(executor.executeFolderQuery).toBeCalledWith({ useCache: false, }); }); it('should execute default folder query with folder view and handle null results', async () => { const executor = mock(); - executor.executeDefaultFolderQuery.mockResolvedValue(null); + executor.executeFolderQuery.mockResolvedValue(null); const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor); const view = createView({ view: 'folder', camera: 'camera.office' }); diff --git a/tests/components-lib/timeline/source.test.ts b/tests/components-lib/timeline/source.test.ts new file mode 100644 index 00000000..6658ab93 --- /dev/null +++ b/tests/components-lib/timeline/source.test.ts @@ -0,0 +1,787 @@ +import { DataSet } from 'vis-data'; +import { TimelineWindow } from 'vis-timeline'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CameraManager } from '../../../src/camera-manager/manager'; +import { + Engine, + EventQuery, + QueryResultsType, + QueryType, + RecordingSegment, + RecordingSegmentsQuery, + RecordingSegmentsQueryResults, +} from '../../../src/camera-manager/types'; +import { + AdvancedCameraCardTimelineItem, + TimelineDataSource, +} from '../../../src/components-lib/timeline/source'; +import { TimelineKey } from '../../../src/components-lib/timeline/types'; +import { ViewMediaType } from '../../../src/view/item'; +import { EventMediaQuery } from '../../../src/view/query'; +import { + createCameraManager, + createFolder, + createStore, + createView, + TestViewMedia, +} from '../../test-utils'; + +const CAMERA_ID = 'CAMERA_ID'; +const TEST_MEDIA_ID = 'TEST_MEDIA_ID'; +const RECORDING_SEGMENT_ID = 'SEGMENT_ID'; +const EXPECTED_RECORDING_ID = `recording-${CAMERA_ID}-${RECORDING_SEGMENT_ID}`; + +const start = new Date('2025-09-21T19:31:06Z'); +const end = new Date('2025-09-21T19:31:15Z'); + +const testMedia = new TestViewMedia({ + cameraID: CAMERA_ID, + id: TEST_MEDIA_ID, + startTime: start, + endTime: end, +}); + +const createTestCameraManager = (): CameraManager => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: CAMERA_ID, + }, + ]), + ); + + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ + title: 'Camera Title', + icon: { icon: 'mdi:camera' }, + }); + const eventQuery: EventQuery = { + type: QueryType.Event, + cameraIDs: new Set([CAMERA_ID]), + start: start, + end: end, + }; + + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([eventQuery]); + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([testMedia]); + + const recordingSegmentQuery: RecordingSegmentsQuery = { + type: QueryType.RecordingSegments, + cameraIDs: new Set([CAMERA_ID]), + start, + end, + }; + vi.mocked(cameraManager.generateDefaultRecordingSegmentsQueries).mockReturnValue([ + recordingSegmentQuery, + ]); + + const recordingSegment: RecordingSegment = { + start_time: 1695307866, + end_time: 1695307875, + id: RECORDING_SEGMENT_ID, + }; + const recordingSegmentsQueryResults: RecordingSegmentsQueryResults = { + type: QueryResultsType.RecordingSegments, + engine: Engine.Generic, + segments: [recordingSegment], + }; + + vi.mocked(cameraManager.getRecordingSegments).mockResolvedValue( + new Map([[recordingSegmentQuery, recordingSegmentsQueryResults]]), + ); + return cameraManager; +}; + +describe('TimelineDataSource', () => { + const folder = createFolder({ id: 'folder/FOLDER_ID', title: 'Folder Title' }); + const timelineKeys: TimelineKey[] = [ + { type: 'camera', cameraID: 'CAMERA_ID' }, + { type: 'folder', folder: folder }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('should get groups', () => { + it('should get mixed groups', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + + expect(source.groups.length).toBe(2); + expect(source.groups.get('camera/CAMERA_ID')).toEqual({ + content: 'Camera Title', + id: 'camera/CAMERA_ID', + }); + expect(source.groups.get('folder/FOLDER_ID')).toEqual({ + content: 'Folder Title', + id: 'folder/FOLDER_ID', + }); + }); + + it('should use camera id if camera has no title', () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + + expect(source.groups.get('camera/CAMERA_ID')).toEqual({ + content: 'CAMERA_ID', + id: 'camera/CAMERA_ID', + }); + }); + + it('should use folder id if folder has no title', () => { + const folder = createFolder({ id: 'folder/FOLDER_ID' }); + const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; + + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + + expect(source.groups.get('folder/FOLDER_ID')).toEqual({ + content: 'folder/FOLDER_ID', + id: 'folder/FOLDER_ID', + }); + }); + }); + + describe('should update events from view', () => { + it('should add camera events to dataset', () => { + const startTime = new Date('2025-09-21T15:32:21Z'); + const endTime = new Date('2025-09-21T15:35:28Z'); + const id = 'EVENT_ID'; + const media = new TestViewMedia({ + cameraID: 'CAMERA_ID', + id, + startTime, + endTime, + }); + + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + source.addEventMediaToDataset([media]); + + expect(source.dataset.length).toBe(1); + expect(source.dataset.get(id)).toEqual({ + id, + start: startTime.getTime(), + end: endTime.getTime(), + media, + group: 'camera/CAMERA_ID', + content: '', + type: 'range', + }); + }); + + it('should add folder events to dataset', () => { + const startTime = new Date('2025-09-21T15:32:21Z'); + const endTime = new Date('2025-09-21T15:35:28Z'); + const id = 'EVENT_ID'; + const folderID = 'folder/FOLDER_ID'; + const folder = createFolder({ id: folderID }); + const media = new TestViewMedia({ + cameraID: null, + id, + startTime, + endTime, + folder, + }); + + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + source.addEventMediaToDataset([media]); + + expect(source.dataset.length).toBe(1); + expect(source.dataset.get(id)).toEqual({ + id, + start: startTime.getTime(), + end: endTime.getTime(), + media, + group: folderID, + content: '', + type: 'range', + }); + }); + + it('should ignore non-events media', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + + source.addEventMediaToDataset([ + new TestViewMedia({ + mediaType: ViewMediaType.Recording, + }), + ]); + + expect(source.dataset.length).toBe(0); + }); + + it('should ignore null results', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + + source.addEventMediaToDataset(null); + + expect(source.dataset.length).toBe(0); + }); + + it('should ignore media without camera or folder ownership', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + + source.addEventMediaToDataset([ + new TestViewMedia({ + cameraID: null, + folder: null, + mediaType: ViewMediaType.Snapshot, + }), + ]); + + expect(source.dataset.length).toBe(0); + }); + }); + + describe('should refresh', () => { + const window: TimelineWindow = { + start: new Date('2025-09-21T19:31:06Z'), + end: new Date('2025-09-21T19:31:15Z'), + }; + + describe('should refresh events', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should refresh events successfully', async () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + false, + ); + const view = createView(); + + await source.refresh(window, view); + + expect(source.dataset.length).toBe(1); + + expect(source.dataset.get('TEST_MEDIA_ID')).toEqual({ + id: 'TEST_MEDIA_ID', + content: '', + start: new Date('2025-09-21T19:31:06Z').getTime(), + end: new Date('2025-09-21T19:31:15Z').getTime(), + media: testMedia, + type: 'range', + group: 'camera/CAMERA_ID', + }); + }); + + it('should refresh events and handle exception', async () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue( + new Error('Error fetching events'), + ); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', false); + + expect(source.dataset.length).toBe(0); + + await source.refresh(window); + + expect(source.dataset.length).toBe(0); + + expect(consoleSpy).toHaveBeenCalledWith('Error fetching events'); + }); + + it('should not refresh events when window is cached', async () => { + const cameraManager = createTestCameraManager(); + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', false); + const view = createView(); + + await source.refresh(window, view); + expect(source.dataset.length).toBe(1); + + await source.refresh(window, view); + expect(source.dataset.length).toBe(1); + expect(cameraManager.executeMediaQueries).toHaveBeenCalledTimes(1); + }); + + it('should not refresh events when events in view', async () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + false, + ); + + await source.refresh(window, createView({ query: new EventMediaQuery() })); + + expect(source.dataset.length).toBe(0); + }); + + it('should not refresh events when unable to create event queries', async () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', false); + + await source.refresh(window); + expect(source.dataset.length).toBe(0); + }); + }); + + describe('should refresh recordings', () => { + const getRecordings = ( + dataset: DataSet, + ): AdvancedCameraCardTimelineItem[] => { + return dataset.get({ filter: (item) => item.type === 'background' }); + }; + + it('should refresh recordings successfully', async () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + + await source.refresh(window); + + // 1 event and 1 recording == 2 total items. + expect(source.dataset.length).toBe(2); + + expect(source.dataset.get(EXPECTED_RECORDING_ID)).toEqual({ + content: '', + end: 1695307875000, + group: 'camera/CAMERA_ID', + id: EXPECTED_RECORDING_ID, + start: 1695307866000, + type: 'background', + }); + }); + + it('should refresh recordings and handle exception', async () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.getRecordingSegments).mockRejectedValue( + new Error('Error fetching recordings'), + ); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + + expect(getRecordings(source.dataset).length).toBe(0); + + await source.refresh(window); + + expect(getRecordings(source.dataset).length).toBe(0); + + expect(consoleSpy).toHaveBeenCalledWith('Error fetching recordings'); + }); + + it('should not refresh recordings when window is cached', async () => { + const cameraManager = createTestCameraManager(); + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + + await source.refresh(window); + expect(getRecordings(source.dataset).length).toBe(1); + + await source.refresh(window); + expect(getRecordings(source.dataset).length).toBe(1); + + expect(cameraManager.getRecordingSegments).toHaveBeenCalledTimes(1); + }); + + it('should not refresh recordings when recordings disabled', async () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + + // Disable recordings. + false, + ); + + await source.refresh(window); + + expect(source.dataset.get(EXPECTED_RECORDING_ID)).toBeNull(); + }); + + it('should not refresh recordings without any cameras', async () => { + const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; + + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + + await source.refresh(window); + + expect(source.dataset.get(EXPECTED_RECORDING_ID)).toBeNull(); + }); + + it('should not refresh recordings without recording queries', async () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.generateDefaultRecordingSegmentsQueries).mockReturnValue( + null, + ); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + + await source.refresh(window); + + expect(getRecordings(source.dataset).length).toBe(0); + expect(cameraManager.getRecordingSegments).toHaveBeenCalledTimes(0); + }); + + it('should compress recording segments', async () => { + const cameraManager = createTestCameraManager(); + + const recordingSegmentQuery: RecordingSegmentsQuery = { + type: QueryType.RecordingSegments, + cameraIDs: new Set([CAMERA_ID]), + start: new Date('2025-09-21T19:31:06Z'), + end: new Date('2025-09-21T19:31:15Z'), + }; + + const recordingSegmentsQueryResults: RecordingSegmentsQueryResults = { + type: QueryResultsType.RecordingSegments, + engine: Engine.Generic, + segments: [ + { + start_time: 1695307866, + end_time: 1695307875, + id: RECORDING_SEGMENT_ID, + }, + { + start_time: 1695307875, + end_time: 1695307885, + id: `${RECORDING_SEGMENT_ID}-2`, + }, + ], + }; + + vi.mocked(cameraManager.getRecordingSegments).mockResolvedValue( + new Map([ + [recordingSegmentQuery, recordingSegmentsQueryResults], + [{ ...recordingSegmentQuery }, recordingSegmentsQueryResults], + ]), + ); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + + await source.refresh(window); + + expect(getRecordings(source.dataset)).toEqual([ + { + content: '', + end: 1695307885000, + group: 'camera/CAMERA_ID', + id: 'recording-CAMERA_ID-SEGMENT_ID', + start: 1695307866000, + type: 'background', + }, + ]); + expect(cameraManager.getRecordingSegments).toHaveBeenCalledTimes(1); + }); + + it('should compress recording segments without an end', async () => { + const cameraManager = createTestCameraManager(); + + const recordingSegmentQuery: RecordingSegmentsQuery = { + type: QueryType.RecordingSegments, + cameraIDs: new Set([CAMERA_ID]), + start: new Date('2025-09-21T19:31:06Z'), + end: new Date('2025-09-21T19:31:15Z'), + }; + + const recordingSegmentsQueryResults: RecordingSegmentsQueryResults = { + type: QueryResultsType.RecordingSegments, + engine: Engine.Generic, + segments: [ + { + start_time: 1695307866, + end_time: 1695307876, + id: RECORDING_SEGMENT_ID, + }, + { + start_time: 1695307875, + end_time: 1695307885, + id: `${RECORDING_SEGMENT_ID}-2`, + }, + ], + }; + + vi.mocked(cameraManager.getRecordingSegments).mockResolvedValue( + new Map([[recordingSegmentQuery, recordingSegmentsQueryResults]]), + ); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + source.dataset.add({ + id: 'recording-CAMERA_ID-SEGMENT_ID', + start: 1695307866000, + + // No end time. + end: undefined, + + group: 'camera/CAMERA_ID', + content: '', + type: 'background', + }); + + await source.refresh(window); + + expect(getRecordings(source.dataset)).toEqual([ + { + content: '', + end: 1695307885000, + group: 'camera/CAMERA_ID', + id: 'recording-CAMERA_ID-SEGMENT_ID', + start: 1695307866000, + type: 'background', + }, + ]); + expect(cameraManager.getRecordingSegments).toHaveBeenCalledTimes(1); + }); + + it('should compress recording segments without mixing up cameras', async () => { + const cameraManager = createTestCameraManager(); + + vi.mocked(cameraManager.getRecordingSegments).mockResolvedValue( + new Map([ + [ + { + type: QueryType.RecordingSegments, + cameraIDs: new Set(['camera-1']), + start: new Date('2025-09-21T19:31:06Z'), + end: new Date('2025-09-21T19:31:15Z'), + }, + { + type: QueryResultsType.RecordingSegments, + engine: Engine.Generic, + segments: [ + { + start_time: 1695307866, + end_time: 1695307875, + id: 'segment-1', + }, + ], + }, + ], + + [ + { + type: QueryType.RecordingSegments, + cameraIDs: new Set(['camera-2']), + start: new Date('2025-09-21T19:31:06Z'), + end: new Date('2025-09-21T19:31:15Z'), + }, + { + type: QueryResultsType.RecordingSegments, + engine: Engine.Generic, + segments: [ + { + start_time: 1695307866, + end_time: 1695307875, + id: 'segment-2', + }, + ], + }, + ], + ]), + ); + + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + + await source.refresh(window); + + expect(getRecordings(source.dataset)).toEqual([ + { + content: '', + end: 1695307875000, + group: 'camera/camera-1', + id: 'recording-camera-1-segment-1', + start: 1695307866000, + type: 'background', + }, + { + content: '', + end: 1695307875000, + group: 'camera/camera-2', + id: 'recording-camera-2-segment-2', + start: 1695307866000, + type: 'background', + }, + ]); + expect(cameraManager.getRecordingSegments).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('should get timeline event queries', () => { + const window: TimelineWindow = { start, end }; + + it('should not event queries without cameras', () => { + const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; + const source = new TimelineDataSource( + createCameraManager(), + timelineKeys, + 'all', + true, + ); + + expect(source.getTimelineEventQueries(window)).toBeNull(); + }); + + it('should get event queries for clips', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: CAMERA_ID, + }, + ]), + ); + const source = new TimelineDataSource(cameraManager, timelineKeys, 'clips', false); + source.getTimelineEventQueries(window); + + expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( + new Set([CAMERA_ID]), + { + start, + end, + hasClip: true, + }, + ); + }); + + it('should get event queries for snapshots', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: CAMERA_ID, + }, + ]), + ); + const source = new TimelineDataSource( + cameraManager, + timelineKeys, + 'snapshots', + false, + ); + source.getTimelineEventQueries(window); + + expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( + new Set([CAMERA_ID]), + { + start, + end, + hasSnapshot: true, + }, + ); + }); + }); + + describe('should get timeline recording queries', () => { + const window: TimelineWindow = { start, end }; + + it('should not recording queries without cameras', () => { + const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; + const source = new TimelineDataSource( + createCameraManager(), + timelineKeys, + 'all', + true, + ); + + expect(source.getTimelineRecordingQueries(window)).toBeNull(); + }); + + it('should get recording queries', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: CAMERA_ID, + }, + ]), + ); + const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + source.getTimelineRecordingQueries(window); + + expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith( + new Set([CAMERA_ID]), + { + start, + end, + }, + ); + }); + }); + + describe('should rewrite event', () => { + it('should not rewrite when item is not found', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + source.rewriteEvent('UNKNOWN_ID'); + + expect(source.dataset.length).toBe(0); + }); + + it('should not rewrite when item is not found', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + timelineKeys, + 'all', + true, + ); + const item = { + id: 'id', + start: start.getTime(), + end: end.getTime(), + media: testMedia, + group: 'camera/CAMERA_ID' as const, + content: '', + type: 'range' as const, + }; + source.dataset.add(item); + + source.rewriteEvent('id'); + + expect(source.dataset.get('id')).toBe(item); + }); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index d79315e7..64756ec7 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -674,6 +674,7 @@ export const createTouchEvent = ( export const createFolder = (config?: Partial): FolderConfig => { return { type: 'ha', + id: crypto.randomUUID(), ha: { path: [{ id: 'media-source://' }], }, diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 17f868a8..97921ded 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -5,7 +5,6 @@ import { ActionConfig } from '../../src/config/schema/actions/types.js'; import { createCameraAction, createDisplayModeAction, - createFoldersViewAction, createGeneralAction, createInternalCallbackAction, createLogAction, @@ -48,6 +47,20 @@ describe('createViewAction', () => { card_id: 'card_id', }); }); + + it.each([['folder' as const], ['folders' as const]])( + '%s', + (viewName: 'folder' | 'folders') => { + expect( + createViewAction(viewName, { folderID: 'folderID', cardID: 'card_id' }), + ).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: viewName, + card_id: 'card_id', + folder: 'folderID', + }); + }, + ); }); describe('createCameraAction', () => { @@ -63,22 +76,6 @@ describe('createCameraAction', () => { }); }); -describe('createFolderAction', () => { - it.each([['folder' as const], ['folders' as const]])( - '%s', - (viewName: 'folder' | 'folders') => { - expect( - createFoldersViewAction(viewName, { folderID: 'folderID', cardID: 'card_id' }), - ).toEqual({ - action: 'fire-dom-event', - advanced_camera_card_action: viewName, - card_id: 'card_id', - folder: 'folderID', - }); - }, - ); -}); - describe('createMediaPlayerAction', () => { it('should create media_player action', () => { expect( diff --git a/vite.config.ts b/vite.config.ts index 483319c8..40938615 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'camera-manager/reolink/*.ts', 'camera-manager/utils/*.ts', 'card-controller/**/*.ts', - 'components-lib/**/!(timeline-source.ts)', + 'components-lib/timeline/!(controller)*.ts', 'conditions/**/*.ts', 'config/**/*.ts', 'const.ts',