`;
}
- /**
- * 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',