Better timeline functionality for recordings.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user