diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 984400f6..3e63d4d7 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1,9 +1,7 @@ import add from 'date-fns/add'; import differenceInSeconds from 'date-fns/differenceInSeconds'; import endOfDay from 'date-fns/endOfDay'; -import endOfHour from 'date-fns/endOfHour'; import startOfDay from 'date-fns/startOfDay'; -import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { CSSResultGroup, @@ -31,6 +29,7 @@ import { import { CameraManager } from '../camera-manager/manager'; import { rangesOverlap } from '../camera-manager/range'; import { MediaQuery } from '../camera-manager/types'; +import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { localize } from '../localize/localize'; import timelineCoreStyle from '../scss/timeline-core.scss'; import { @@ -51,16 +50,19 @@ import { isTruthy, setOrRemoveAttribute, } from '../utils/basic'; -import { - createQueriesForRecordingsView, - executeMediaQueryForView, - findBestMediaIndex, -} from '../utils/media-to-view'; +import { executeMediaQueryForView, findBestMediaIndex } from '../utils/media-to-view'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; -import { EventMediaQueries, MediaQueries } from '../view/media-queries'; -import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { + EventMediaQueries, + MediaQueries, + RecordingMediaQueries, +} from '../view/media-queries'; +import { + MediaQueriesClassifier, + MediaQueriesType, +} from '../view/media-queries-classifier'; import { View } from '../view/view'; import './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; @@ -541,6 +543,7 @@ export class FrigateCardTimelineCore extends LitElement { !this.cameraManager || !this.cardWideConfig || !timelineCameraIDs || + !this._timelineSource || !properties.what ) { return; @@ -551,45 +554,10 @@ export class FrigateCardTimelineCore extends LitElement { if ( this.timelineConfig?.show_recordings && - ['background', 'group-label'].includes(properties.what) + properties.time && + ['background', 'axis'].includes(properties.what) ) { - const cameraIDs = properties.group - ? new Set([String(properties.group)]) - : this._getTimelineCameraIDs(); - const query = cameraIDs - ? createQueriesForRecordingsView( - this.cameraManager, - this.cardWideConfig, - cameraIDs, - ) - : null; - if (query) { - view = await executeMediaQueryForView( - this, - this.hass, - this.cameraManager, - this.view, - query, - { - targetView: 'recording', - targetTime: - properties.what === 'background' - ? properties.time - : this._timeline.getWindow().end, - select: 'time', - }, - ); - } - } else if (this.timelineConfig?.show_recordings && properties.what === 'axis') { - const query = createQueriesForRecordingsView( - this.cameraManager, - this.cardWideConfig, - timelineCameraIDs, - { - start: startOfHour(properties.time), - end: endOfHour(properties.time), - }, - ); + const query = this._createMediaQueries('recording'); if (query) { view = await executeMediaQueryForView( this, @@ -623,8 +591,8 @@ export class FrigateCardTimelineCore extends LitElement { // 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 fullEventView = await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys(), + const fullEventView = await this._createViewWithMediaQueries( + this._createMediaQueries('event'), { selectedItem: properties.item, targetView: 'media', @@ -686,59 +654,58 @@ export class FrigateCardTimelineCore extends LitElement { } this._removeTargetBar(); - if (!this.hass) { + if (!this.hass || !this._timeline || !this.view) { return; } - const prefetchedWindow = this._getPrefetchWindow(properties); - await this._timelineSource?.refresh(this.hass, prefetchedWindow); + await this._timelineSource?.refresh(this.hass, this._getPrefetchWindow(properties)); - // Don't show event thumbnails if the user is looking at recordings, - // as the recording "hours" are the media, not the event - // clips/snapshots. - if ( - this._timeline && - this.view && - !MediaQueriesClassifier.areRecordingQueries(this.view.query) - ) { - const newView = await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys({ window: this._timeline.getWindow() }), - ); + const queryType = MediaQueriesClassifier.getQueriesType(this.view.query); + if (!queryType) { + return; + } + const mediaQuery = this._createMediaQueries(queryType); + const newView = await this._createViewWithMediaQueries(mediaQuery); - // Specifically avoid dispatching new results on range change unless there - // is something to be gained by doing so. Example usecase: On initial view - // load in mini timeline mode, the first 50 events are fetched -- the - // first drag of the timeline should not dispatch new results unless - // something is actually useful (as otherwise it creates a visible - // 'flicker' for the user as the viewer reloads all the media). - const newResults = newView?.queryResults; - if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { - newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); - } + // Specifically avoid dispatching new results on range change unless there + // is something to be gained by doing so. Example usecase: On initial view + // load in mini timeline mode, the first 50 events are fetched -- the + // first drag of the timeline should not dispatch new results unless + // something is actually useful (as otherwise it creates a visible + // 'flicker' for the user as the viewer reloads all the media). + const newResults = newView?.queryResults; + if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { + newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); } } - protected _createEventMediaQuerys(options?: { - window?: TimelineWindow; - }): EventMediaQueries | null { - if (!this._timeline || !this._timelineSource || !this.cardWideConfig) { + protected _createMediaQueries( + type: MediaQueriesType, + options?: { + window?: TimelineWindow; + }, + ): MediaQueries | null { + if (!this._timeline || !this._timelineSource) { return null; } - const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow( - options?.window ?? this._timeline.getWindow(), + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes( + this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()), ); - const eventQueries = - this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); - if (!eventQueries) { - return null; + if (type === 'event') { + const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); + return queries ? new EventMediaQueries(queries) : null; + } else if (type === 'recording') { + const queries = + this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow); + return queries ? new RecordingMediaQueries(queries) : null; } - return new EventMediaQueries(eventQueries); + return null; } - protected async _createViewWithEventMediaQuery( - query: EventMediaQueries | null, + protected async _createViewWithMediaQueries( + query: MediaQueries | null, options?: { targetView?: FrigateCardView; selectedItem?: IdType; @@ -1089,20 +1056,23 @@ export class FrigateCardTimelineCore extends LitElement { // -> New view received ... [loop] // // Also don't generate thumbnails in mini-timelines (they will already have - // been generated), or if the view is for recordings (media thumbnails are - // recordings, not events in this case). + // been generated). - const freshMediaQuery = this._createEventMediaQuerys({ + const queryType = MediaQueriesClassifier.getQueriesType(this.view.query); + if (!queryType) { + return; + } + + const freshMediaQuery = this._createMediaQueries(queryType, { window: desiredWindow, }); if ( !this.mini && - !MediaQueriesClassifier.areRecordingQueries(this.view.query) && freshMediaQuery && !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { - (await this._createViewWithEventMediaQuery(freshMediaQuery)) + (await this._createViewWithMediaQueries(freshMediaQuery)) ?.mergeInContext(this._getTimelineContext(desiredWindow)) .dispatchChangeEvent(this); } diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 299cd2f4..6d876cc9 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -84,7 +84,6 @@ div.timeline { cursor: pointer; } :host([recordings]) .vis-item.vis-background, -:host([recordings]) .vis-labelset, :host([recordings]) .vis-time-axis { cursor: crosshair; } @@ -119,8 +118,7 @@ div.timeline { .vis-label { transition: background-color 0.5s ease-out; } -:host([recordings]) .vis-text.vis-minor:hover, -:host([recordings]) .vis-label:hover { +:host([recordings]) .vis-text.vis-minor:hover { background-color: var(--primary-color); } diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 6e0059f3..b47b7a8b 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -116,21 +116,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( )?.dispatchChangeEvent(element); }; -export const createQueriesForRecordingsView = ( +const createQueriesForRecordingsView = ( cameraManager: CameraManager, cardWideConfig: CardWideConfig, cameraIDs: Set, - options?: { - start?: Date; - end?: Date; - }, ): RecordingMediaQueries | null => { const limit = cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT; const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { limit: limit, - ...(options?.start && { start: options.start }), - ...(options?.end && { end: options.end }), }); return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null; }; diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index f2646457..e50299b1 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -5,7 +5,7 @@ import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { ClipsOrSnapshotsOrAll } from '../types'; import { CameraManager } from '../camera-manager/manager'; -import { EventQuery, RecordingSegment } from '../camera-manager/types'; +import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { ViewMedia } from '../view/media'; import { @@ -98,12 +98,6 @@ export class TimelineDataSource { } } - public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow { - return convertRangeToCacheFriendlyTimes(window, { - endCap: true, - }); - } - public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { start: window.start, @@ -113,6 +107,13 @@ export class TimelineDataSource { }); } + public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null { + return this._cameraManager.generateDefaultRecordingQueries(this._cameraIDs, { + start: window.start, + end: window.end, + }); + } + protected async _refreshEvents( hass: HomeAssistant, window: TimelineWindow, @@ -127,8 +128,7 @@ export class TimelineDataSource { ) { return; } - - const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window); + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window); const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow); if (!eventQueries) { return; @@ -217,10 +217,7 @@ export class TimelineDataSource { return; } - const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, { - endCap: true, - }); - + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window); const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries( this._cameraIDs, { diff --git a/src/view/media-queries-classifier.ts b/src/view/media-queries-classifier.ts index 4508e97f..b63210bc 100644 --- a/src/view/media-queries-classifier.ts +++ b/src/view/media-queries-classifier.ts @@ -1,5 +1,6 @@ import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries'; +export type MediaQueriesType = 'event' | 'recording'; export class MediaQueriesClassifier { public static areEventQueries( queries?: MediaQueries | null, @@ -12,4 +13,12 @@ export class MediaQueriesClassifier { ): queries is RecordingMediaQueries { return queries instanceof RecordingMediaQueries; } + + public static getQueriesType(queries?: MediaQueries | null): MediaQueriesType | null { + return this.areEventQueries(queries) + ? 'event' + : this.areRecordingQueries(queries) + ? 'recording' + : null; + } } diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts index 561bbc65..5e8974d5 100644 --- a/tests/utils/media-to-view.test.ts +++ b/tests/utils/media-to-view.test.ts @@ -1,16 +1,10 @@ import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { - CameraConfigs, - PartialRecordingQuery, - QueryType, -} from '../../src/camera-manager/types'; -import { setify } from '../../src/utils/basic'; +import { CameraConfigs } from '../../src/camera-manager/types'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, - createQueriesForRecordingsView, executeMediaQueryForView, findBestMediaIndex, } from '../../src/utils/media-to-view'; @@ -400,45 +394,6 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { }); }); -// @vitest-environment jsdom -describe('createQueriesForRecordingsView', () => { - it('should respect start and end date in recording query', async () => { - const cameraManager = createCameraManager({ configs: new Map() }); - - vi.mocked(cameraManager.generateDefaultRecordingQueries).mockImplementation( - (cameraIDs: string | Set, partialQuery?: PartialRecordingQuery) => [ - { - cameraIDs: setify(cameraIDs), - type: QueryType.Recording, - ...partialQuery, - }, - ], - ); - - const start = new Date('2023-04-29T14:00:00'); - const end = new Date('2023-04-29T14:59:59'); - - const queries = createQueriesForRecordingsView( - cameraManager, - {}, - new Set(['camera']), - { - start: start, - end: end, - }, - ); - - expect(queries?.getQueries()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - start: start, - end: end, - }), - ]), - ); - }); -}); - // @vitest-environment jsdom describe('findBestMediaIndex', () => { it('should find best media index', async () => { diff --git a/tests/view/media-queries-classifier.test.ts b/tests/view/media-queries-classifier.test.ts new file mode 100644 index 00000000..0b96608b --- /dev/null +++ b/tests/view/media-queries-classifier.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; +import { MediaQueriesClassifier } from '../../src/view/media-queries-classifier'; + +describe('MediaQueriesClassifier', () => { + it('areEventQueries', () => { + expect(MediaQueriesClassifier.areEventQueries(new EventMediaQueries())).toBeTruthy(); + expect( + MediaQueriesClassifier.areEventQueries(new RecordingMediaQueries()), + ).toBeFalsy(); + }); + + it('areRecordingQueries', () => { + expect( + MediaQueriesClassifier.areRecordingQueries(new RecordingMediaQueries()), + ).toBeTruthy(); + expect( + MediaQueriesClassifier.areRecordingQueries(new EventMediaQueries()), + ).toBeFalsy(); + }); + + it('getQueriesType', () => { + expect(MediaQueriesClassifier.getQueriesType(new EventMediaQueries())).toBe('event'); + expect(MediaQueriesClassifier.getQueriesType(new RecordingMediaQueries())).toBe( + 'recording', + ); + expect(MediaQueriesClassifier.getQueriesType()).toBeNull(); + }); +});