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 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);
}
+1 -3
View File
@@ -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);
}
+1 -7
View File
@@ -116,21 +116,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
)?.dispatchChangeEvent(element);
};
export const createQueriesForRecordingsView = (
const createQueriesForRecordingsView = (
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
cameraIDs: Set<string>,
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;
};
+10 -13
View File
@@ -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,
{
+9
View File
@@ -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;
}
}
+1 -46
View File
@@ -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<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
describe('findBestMediaIndex', () => {
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();
});
});