Better timeline functionality for recordings.

This commit is contained in:
Dermot Duffy
2023-08-20 15:50:37 -07:00
parent 2df22b96ea
commit 0e5e99419f
7 changed files with 112 additions and 160 deletions
+61 -91
View File
@@ -1,9 +1,7 @@
import add from 'date-fns/add'; import add from 'date-fns/add';
import differenceInSeconds from 'date-fns/differenceInSeconds'; import differenceInSeconds from 'date-fns/differenceInSeconds';
import endOfDay from 'date-fns/endOfDay'; import endOfDay from 'date-fns/endOfDay';
import endOfHour from 'date-fns/endOfHour';
import startOfDay from 'date-fns/startOfDay'; import startOfDay from 'date-fns/startOfDay';
import startOfHour from 'date-fns/startOfHour';
import sub from 'date-fns/sub'; import sub from 'date-fns/sub';
import { import {
CSSResultGroup, CSSResultGroup,
@@ -31,6 +29,7 @@ import {
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range'; import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types'; import { MediaQuery } from '../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss'; import timelineCoreStyle from '../scss/timeline-core.scss';
import { import {
@@ -51,16 +50,19 @@ import {
isTruthy, isTruthy,
setOrRemoveAttribute, setOrRemoveAttribute,
} from '../utils/basic'; } from '../utils/basic';
import { import { executeMediaQueryForView, findBestMediaIndex } from '../utils/media-to-view';
createQueriesForRecordingsView,
executeMediaQueryForView,
findBestMediaIndex,
} from '../utils/media-to-view';
import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
import { EventMediaQueries, MediaQueries } from '../view/media-queries'; import {
import { MediaQueriesClassifier } from '../view/media-queries-classifier'; EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../view/media-queries';
import {
MediaQueriesClassifier,
MediaQueriesType,
} from '../view/media-queries-classifier';
import { View } from '../view/view'; import { View } from '../view/view';
import './date-picker.js'; import './date-picker.js';
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
@@ -541,6 +543,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this.cameraManager || !this.cameraManager ||
!this.cardWideConfig || !this.cardWideConfig ||
!timelineCameraIDs || !timelineCameraIDs ||
!this._timelineSource ||
!properties.what !properties.what
) { ) {
return; return;
@@ -551,45 +554,10 @@ export class FrigateCardTimelineCore extends LitElement {
if ( if (
this.timelineConfig?.show_recordings && this.timelineConfig?.show_recordings &&
['background', 'group-label'].includes(properties.what) properties.time &&
['background', 'axis'].includes(properties.what)
) { ) {
const cameraIDs = properties.group const query = this._createMediaQueries('recording');
? 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),
},
);
if (query) { if (query) {
view = await executeMediaQueryForView( view = await executeMediaQueryForView(
this, this,
@@ -623,8 +591,8 @@ export class FrigateCardTimelineCore extends LitElement {
// gallery (i.e. any case where the thumbnails may not be match the // gallery (i.e. any case where the thumbnails may not be match the
// events on the timeline, e.g. in the snapshots viewer but // events on the timeline, e.g. in the snapshots viewer but
// mini-timeline showing all media). // mini-timeline showing all media).
const fullEventView = await this._createViewWithEventMediaQuery( const fullEventView = await this._createViewWithMediaQueries(
this._createEventMediaQuerys(), this._createMediaQueries('event'),
{ {
selectedItem: properties.item, selectedItem: properties.item,
targetView: 'media', targetView: 'media',
@@ -686,59 +654,58 @@ export class FrigateCardTimelineCore extends LitElement {
} }
this._removeTargetBar(); this._removeTargetBar();
if (!this.hass) { if (!this.hass || !this._timeline || !this.view) {
return; return;
} }
const prefetchedWindow = this._getPrefetchWindow(properties); await this._timelineSource?.refresh(this.hass, this._getPrefetchWindow(properties));
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
// Don't show event thumbnails if the user is looking at recordings, const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
// as the recording "hours" are the media, not the event if (!queryType) {
// clips/snapshots. return;
if ( }
this._timeline && const mediaQuery = this._createMediaQueries(queryType);
this.view && const newView = await this._createViewWithMediaQueries(mediaQuery);
!MediaQueriesClassifier.areRecordingQueries(this.view.query)
) {
const newView = await this._createViewWithEventMediaQuery(
this._createEventMediaQuerys({ window: this._timeline.getWindow() }),
);
// Specifically avoid dispatching new results on range change unless there // Specifically avoid dispatching new results on range change unless there
// is something to be gained by doing so. Example usecase: On initial view // 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 // load in mini timeline mode, the first 50 events are fetched -- the
// first drag of the timeline should not dispatch new results unless // first drag of the timeline should not dispatch new results unless
// something is actually useful (as otherwise it creates a visible // something is actually useful (as otherwise it creates a visible
// 'flicker' for the user as the viewer reloads all the media). // 'flicker' for the user as the viewer reloads all the media).
const newResults = newView?.queryResults; const newResults = newView?.queryResults;
if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) {
newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
}
} }
} }
protected _createEventMediaQuerys(options?: { protected _createMediaQueries(
window?: TimelineWindow; type: MediaQueriesType,
}): EventMediaQueries | null { options?: {
if (!this._timeline || !this._timelineSource || !this.cardWideConfig) { window?: TimelineWindow;
},
): MediaQueries | null {
if (!this._timeline || !this._timelineSource) {
return null; return null;
} }
const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow( const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
options?.window ?? this._timeline.getWindow(), this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
); );
const eventQueries = if (type === 'event') {
this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
if (!eventQueries) { return queries ? new EventMediaQueries(queries) : null;
return 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( protected async _createViewWithMediaQueries(
query: EventMediaQueries | null, query: MediaQueries | null,
options?: { options?: {
targetView?: FrigateCardView; targetView?: FrigateCardView;
selectedItem?: IdType; selectedItem?: IdType;
@@ -1089,20 +1056,23 @@ export class FrigateCardTimelineCore extends LitElement {
// -> New view received ... [loop] // -> New view received ... [loop]
// //
// Also don't generate thumbnails in mini-timelines (they will already have // Also don't generate thumbnails in mini-timelines (they will already have
// been generated), or if the view is for recordings (media thumbnails are // been generated).
// recordings, not events in this case).
const freshMediaQuery = this._createEventMediaQuerys({ const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
if (!queryType) {
return;
}
const freshMediaQuery = this._createMediaQueries(queryType, {
window: desiredWindow, window: desiredWindow,
}); });
if ( if (
!this.mini && !this.mini &&
!MediaQueriesClassifier.areRecordingQueries(this.view.query) &&
freshMediaQuery && freshMediaQuery &&
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery) !this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
) { ) {
(await this._createViewWithEventMediaQuery(freshMediaQuery)) (await this._createViewWithMediaQueries(freshMediaQuery))
?.mergeInContext(this._getTimelineContext(desiredWindow)) ?.mergeInContext(this._getTimelineContext(desiredWindow))
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
+1 -3
View File
@@ -84,7 +84,6 @@ div.timeline {
cursor: pointer; cursor: pointer;
} }
:host([recordings]) .vis-item.vis-background, :host([recordings]) .vis-item.vis-background,
:host([recordings]) .vis-labelset,
:host([recordings]) .vis-time-axis { :host([recordings]) .vis-time-axis {
cursor: crosshair; cursor: crosshair;
} }
@@ -119,8 +118,7 @@ div.timeline {
.vis-label { .vis-label {
transition: background-color 0.5s ease-out; transition: background-color 0.5s ease-out;
} }
:host([recordings]) .vis-text.vis-minor:hover, :host([recordings]) .vis-text.vis-minor:hover {
:host([recordings]) .vis-label:hover {
background-color: var(--primary-color); background-color: var(--primary-color);
} }
+1 -7
View File
@@ -116,21 +116,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
}; };
export const createQueriesForRecordingsView = ( const createQueriesForRecordingsView = (
cameraManager: CameraManager, cameraManager: CameraManager,
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
cameraIDs: Set<string>, cameraIDs: Set<string>,
options?: {
start?: Date;
end?: Date;
},
): RecordingMediaQueries | null => { ): RecordingMediaQueries | null => {
const limit = const limit =
cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT; cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
limit: limit, limit: limit,
...(options?.start && { start: options.start }),
...(options?.end && { end: options.end }),
}); });
return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null; return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null;
}; };
+10 -13
View File
@@ -5,7 +5,7 @@ import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { ClipsOrSnapshotsOrAll } from '../types'; import { ClipsOrSnapshotsOrAll } from '../types';
import { CameraManager } from '../camera-manager/manager'; 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 { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { import {
@@ -98,12 +98,6 @@ export class TimelineDataSource {
} }
} }
public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow {
return convertRangeToCacheFriendlyTimes(window, {
endCap: true,
});
}
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
start: window.start, 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( protected async _refreshEvents(
hass: HomeAssistant, hass: HomeAssistant,
window: TimelineWindow, window: TimelineWindow,
@@ -127,8 +128,7 @@ export class TimelineDataSource {
) { ) {
return; return;
} }
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window);
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow); const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
if (!eventQueries) { if (!eventQueries) {
return; return;
@@ -217,10 +217,7 @@ export class TimelineDataSource {
return; return;
} }
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, { const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
endCap: true,
});
const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries( const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries(
this._cameraIDs, this._cameraIDs,
{ {
+9
View File
@@ -1,5 +1,6 @@
import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries'; import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries';
export type MediaQueriesType = 'event' | 'recording';
export class MediaQueriesClassifier { export class MediaQueriesClassifier {
public static areEventQueries( public static areEventQueries(
queries?: MediaQueries | null, queries?: MediaQueries | null,
@@ -12,4 +13,12 @@ export class MediaQueriesClassifier {
): queries is RecordingMediaQueries { ): queries is RecordingMediaQueries {
return queries instanceof RecordingMediaQueries; return queries instanceof RecordingMediaQueries;
} }
public static getQueriesType(queries?: MediaQueries | null): MediaQueriesType | null {
return this.areEventQueries(queries)
? 'event'
: this.areRecordingQueries(queries)
? 'recording'
: null;
}
} }
+1 -46
View File
@@ -1,16 +1,10 @@
import add from 'date-fns/add'; import add from 'date-fns/add';
import sub from 'date-fns/sub'; import sub from 'date-fns/sub';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { import { CameraConfigs } from '../../src/camera-manager/types';
CameraConfigs,
PartialRecordingQuery,
QueryType,
} from '../../src/camera-manager/types';
import { setify } from '../../src/utils/basic';
import { import {
changeViewToRecentEventsForCameraAndDependents, changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents,
createQueriesForRecordingsView,
executeMediaQueryForView, executeMediaQueryForView,
findBestMediaIndex, findBestMediaIndex,
} from '../../src/utils/media-to-view'; } 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<string>, 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 // @vitest-environment jsdom
describe('findBestMediaIndex', () => { describe('findBestMediaIndex', () => {
it('should find best media index', async () => { it('should find best media index', async () => {
@@ -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();
});
});