Initial draft of recordings support.
This commit is contained in:
+365
-71
@@ -1,5 +1,15 @@
|
||||
// TODO: In viewer, the seek is being applied to the 2nd media. Change away from play_time?
|
||||
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { add, fromUnixTime, sub } from 'date-fns';
|
||||
import {
|
||||
add,
|
||||
endOfHour,
|
||||
format,
|
||||
fromUnixTime,
|
||||
getUnixTime,
|
||||
startOfHour,
|
||||
sub
|
||||
} from 'date-fns';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
@@ -29,32 +39,44 @@ import timelineStyle from '../scss/timeline.scss';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateCardConfigDefaults,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
TimelineConfig
|
||||
} from '../types';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||
import { dispatchFrigateCardEvent, prettifyTitle } from '../utils/basic';
|
||||
import { getCameraTitle } from '../utils/camera.js';
|
||||
import {
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
RecordingSegments,
|
||||
RecordingSummary
|
||||
} from '../utils/frigate';
|
||||
import {
|
||||
createEventParentForChildren,
|
||||
createVideoChild,
|
||||
generateRecordingIdentifier,
|
||||
getBrowseMediaQueryParameters,
|
||||
isTrueMedia,
|
||||
multipleBrowseMediaQuery
|
||||
} from '../utils/ha/browse-media';
|
||||
import { View, ViewContext } from '../view';
|
||||
import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js';
|
||||
import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js';
|
||||
import './surround-thumbnails.js';
|
||||
|
||||
const TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS = 10;
|
||||
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
||||
|
||||
interface FrigateCardGroupData {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
interface FrigateCardTimelineItem extends TimelineItem {
|
||||
event: FrigateEvent;
|
||||
start: number;
|
||||
end?: number;
|
||||
event?: FrigateEvent;
|
||||
source?: FrigateBrowseMediaSource;
|
||||
}
|
||||
|
||||
@@ -68,12 +90,17 @@ interface TimelineViewContext extends ViewContext {
|
||||
|
||||
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
|
||||
|
||||
interface CameraRecordings {
|
||||
segments: RecordingSegments;
|
||||
summary: RecordingSummary;
|
||||
}
|
||||
|
||||
const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||
|
||||
/**
|
||||
* A manager to maintain/fetch timeline events.
|
||||
*/
|
||||
class TimelineEventManager {
|
||||
class TimelineDataManager {
|
||||
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
||||
|
||||
// The earliest date managed.
|
||||
@@ -87,7 +114,7 @@ class TimelineEventManager {
|
||||
|
||||
// The maximum allowable age of fetch data (will not fetch more frequently
|
||||
// than this).
|
||||
protected _maxAgeSeconds: number = TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS;
|
||||
protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
|
||||
|
||||
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
|
||||
protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
|
||||
@@ -183,9 +210,7 @@ class TimelineEventManager {
|
||||
* @param end An optional end of the date range.
|
||||
* @returns
|
||||
*/
|
||||
public hasCoverage(start: Date, end?: Date): boolean {
|
||||
const now = new Date().getTime();
|
||||
|
||||
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
|
||||
// Never fetched: no coverage.
|
||||
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
|
||||
return false;
|
||||
@@ -194,7 +219,7 @@ class TimelineEventManager {
|
||||
// If the most recent fetch is older than maxAgeSeconds: no coverage.
|
||||
if (
|
||||
this._maxAgeSeconds &&
|
||||
now - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
||||
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -219,7 +244,7 @@ class TimelineEventManager {
|
||||
return false;
|
||||
}
|
||||
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
|
||||
if (now - end.getTime() > this._maxAgeSeconds * 1000) {
|
||||
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -237,21 +262,108 @@ class TimelineEventManager {
|
||||
* @param end Fetch events that start earlier than this date.
|
||||
* @returns `true` if events were fetched, `false` otherwise.
|
||||
*/
|
||||
public async fetchEventsIfNecessary(
|
||||
public async fetchIfNecessary(
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
hass: ExtendedHomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: TimelineMediaType,
|
||||
eventMedia: TimelineMediaType,
|
||||
start: Date,
|
||||
end: Date,
|
||||
recordings?: boolean,
|
||||
): Promise<boolean> {
|
||||
if (this.hasCoverage(start, end)) {
|
||||
const now = new Date();
|
||||
if (this.hasCoverage(now, start, end)) {
|
||||
return false;
|
||||
}
|
||||
await this._fetchEvents(element, hass, cameras, media, start, end);
|
||||
|
||||
// Cannot fetch the future.
|
||||
end = end > now ? now : end;
|
||||
|
||||
if (!this._dateStart || start < this._dateStart) {
|
||||
this._dateStart = start;
|
||||
}
|
||||
if (!this._dateEnd || end > this._dateEnd) {
|
||||
this._dateEnd = end;
|
||||
}
|
||||
this._dateFetch = new Date();
|
||||
|
||||
await Promise.all([
|
||||
// Events are always fetched for the maximum extent of the managed
|
||||
// range. This is because events may change at any point in time
|
||||
// (e.g. a long-running event that ends).
|
||||
this._fetchEvents(
|
||||
element,
|
||||
hass,
|
||||
cameras,
|
||||
eventMedia,
|
||||
this._dateStart,
|
||||
this._dateEnd,
|
||||
),
|
||||
...(recordings ? [this._fetchRecordings(element, hass, cameras)] : []),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recording hours for the timeline.
|
||||
* @param element The element to send error events from.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param cameras The cameras map.
|
||||
* @param start Fetch events that start later than this date.
|
||||
* @param end Fetch events that start earlier than this date.
|
||||
*/
|
||||
protected async _fetchRecordings(
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<void> {
|
||||
const items: FrigateCardTimelineItem[] = [];
|
||||
const now = new Date();
|
||||
|
||||
const storeRecordings = async (
|
||||
camera: string,
|
||||
config: CameraConfig,
|
||||
): Promise<void> => {
|
||||
if (!config.camera_name) {
|
||||
return;
|
||||
}
|
||||
let summary: RecordingSummary;
|
||||
try {
|
||||
summary = await getRecordingsSummary(hass, config.client_id, config.camera_name);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
|
||||
for (const dayData of summary) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const endHour = endOfHour(hour);
|
||||
items.push({
|
||||
id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`,
|
||||
group: camera,
|
||||
start: getUnixTime(startOfHour(hour)) * 1000,
|
||||
|
||||
// Don't let the recordings show off into the future (even though it
|
||||
// is intended to be indicative of any recordings within that hour
|
||||
// -- it still looks strange!)
|
||||
end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000,
|
||||
type: 'background',
|
||||
content: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) =>
|
||||
storeRecordings(camera, config),
|
||||
),
|
||||
);
|
||||
|
||||
this._dataset.update(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events for the timeline.
|
||||
* @param element The element to send error events from.
|
||||
@@ -265,34 +377,16 @@ class TimelineEventManager {
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: TimelineMediaType,
|
||||
start?: Date,
|
||||
end?: Date,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<void> {
|
||||
if (!this._dateStart || (start && start < this._dateStart)) {
|
||||
this._dateStart = start;
|
||||
}
|
||||
if (!this._dateEnd || (end && end > this._dateEnd)) {
|
||||
this._dateEnd = end;
|
||||
}
|
||||
if (!this._dateStart || !this._dateEnd) {
|
||||
return;
|
||||
}
|
||||
this._dateFetch = new Date();
|
||||
|
||||
const params: BrowseMediaQueryParameters[] = [];
|
||||
cameras.forEach((cameraConfig, cameraID) => {
|
||||
(media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
|
||||
if (
|
||||
this._dateEnd &&
|
||||
this._dateStart &&
|
||||
cameraConfig.camera_name !== CAMERA_BIRDSEYE
|
||||
) {
|
||||
if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) {
|
||||
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
|
||||
// Events are always fetched for the maximum extent of the managed
|
||||
// range. This is because events may change at any point in time
|
||||
// (e.g. a long-running event that ends).
|
||||
before: this._dateEnd.getTime() / 1000,
|
||||
after: this._dateStart.getTime() / 1000,
|
||||
before: end.getTime() / 1000,
|
||||
after: start.getTime() / 1000,
|
||||
unlimited: true,
|
||||
mediaType: mediaType as 'clips' | 'snapshots',
|
||||
});
|
||||
@@ -311,7 +405,7 @@ class TimelineEventManager {
|
||||
try {
|
||||
results = await multipleBrowseMediaQuery(hass, params);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(element, (e as Error).message);
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
|
||||
for (const [query, result] of results.entries()) {
|
||||
@@ -325,7 +419,7 @@ class TimelineEventManager {
|
||||
@customElement('frigate-card-timeline')
|
||||
export class FrigateCardTimeline extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant;
|
||||
protected hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
@@ -371,7 +465,7 @@ export class FrigateCardTimeline extends LitElement {
|
||||
@customElement('frigate-card-timeline-core')
|
||||
export class FrigateCardTimelineCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant;
|
||||
protected hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
@@ -382,12 +476,17 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected timelineConfig?: TimelineConfig;
|
||||
|
||||
protected _events = new TimelineEventManager({
|
||||
protected _data = new TimelineDataManager({
|
||||
tooltipCallback: this._getTooltip.bind(this),
|
||||
});
|
||||
protected _refTimeline: Ref<HTMLElement> = createRef();
|
||||
protected _timeline?: Timeline;
|
||||
|
||||
// Need a way to separate when a user clicks (to pan the timeline) vs when a
|
||||
// user clicks (to choose a recording (non-event) to play). On pan,
|
||||
// _wasDragged will be set to true, and the click subsequently ignored.
|
||||
protected _wasDragged = false;
|
||||
|
||||
/**
|
||||
* Get a tooltip for a given timeline event.
|
||||
* @param source The FrigateBrowseMediaSource in question.
|
||||
@@ -446,12 +545,206 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of seconds to seek into a video stream consisting of the
|
||||
* provided segments to reach the target time provided.
|
||||
* @param time Target time.
|
||||
* @param segments A RecordingSegments object.
|
||||
* @returns
|
||||
*/
|
||||
protected _getSeekTime(time: Date, segments: RecordingSegments): number | null {
|
||||
if (!segments.length) {
|
||||
return null;
|
||||
}
|
||||
const target = getUnixTime(time);
|
||||
const hourStart = getUnixTime(startOfHour(time));
|
||||
let seekSeconds = 0;
|
||||
|
||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||
for (const segment of segments) {
|
||||
if (segment.start_time > target) {
|
||||
break;
|
||||
}
|
||||
const start = segment.start_time < hourStart ? hourStart : segment.start_time;
|
||||
const end = segment.end_time > target ? target : segment.end_time;
|
||||
seekSeconds += end - start;
|
||||
}
|
||||
return seekSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create recording objects.
|
||||
* @param results A map of camera ID to a CameraRecordings object.
|
||||
* @param time The target time for the recordings.
|
||||
* @param onlyMatchingHour If `true` only shows the hour matching the target
|
||||
* for the provided cameras, otherwise shows all hours.
|
||||
* @returns
|
||||
*/
|
||||
protected _createRecordingChildren(
|
||||
results: Map<string, CameraRecordings>,
|
||||
time: Date,
|
||||
onlyMatchingHour: boolean,
|
||||
): FrigateBrowseMediaSource[] {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
const processedCameras: Set<string> = new Set();
|
||||
|
||||
for (const [camera, recording] of results.entries()) {
|
||||
const config = this.cameras?.get(camera);
|
||||
if (!config?.camera_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// There is a single set of recordings for a given Frigate camera name.
|
||||
// Zones on that same camera do not get separate recordings. The card may
|
||||
// have multiple instances of the same camera for different zoness, so
|
||||
// need to enforce uniqueness here.
|
||||
const uniqueID = `${config.client_id}/${config.camera_name}`;
|
||||
if (processedCameras.has(uniqueID)) {
|
||||
continue;
|
||||
}
|
||||
processedCameras.add(uniqueID);
|
||||
|
||||
const seekSeconds = this._getSeekTime(time, recording.segments);
|
||||
if (seekSeconds === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dayData of recording.summary) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
const isMatchingHour = time >= startHour && time <= endHour;
|
||||
|
||||
if (!onlyMatchingHour || isMatchingHour) {
|
||||
children.push(
|
||||
createVideoChild(
|
||||
`${prettifyTitle(config.camera_name)} ${format(
|
||||
hour,
|
||||
'yyyy-MM-dd HH:mm',
|
||||
)}`,
|
||||
generateRecordingIdentifier({
|
||||
clientId: config.client_id,
|
||||
year: dayData.day.getFullYear(),
|
||||
month: dayData.day.getMonth() + 1,
|
||||
day: dayData.day.getDate(),
|
||||
hour: hourData.hour,
|
||||
cameraName: config.camera_name,
|
||||
}),
|
||||
{
|
||||
recording: {
|
||||
camera: config.camera_name,
|
||||
start_time: getUnixTime(startHour),
|
||||
end_time: getUnixTime(endHour),
|
||||
events: hourData.events,
|
||||
...(isMatchingHour && { play_time: seekSeconds }),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the view to a recording.
|
||||
* @param time The time of the recording to show.
|
||||
* @param camera An optional camera to show a recording of, otherwise all
|
||||
* cameras are shown at the given time.
|
||||
*/
|
||||
protected async _changeViewToRecording(time: Date, camera?: string): Promise<void> {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = endOfHour(time);
|
||||
const after = startOfHour(time);
|
||||
const results: Map<string, CameraRecordings> = new Map();
|
||||
|
||||
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
|
||||
if (!config || !config.camera_name || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cameraResults = await Promise.all([
|
||||
getRecordingSegments(
|
||||
this.hass,
|
||||
config.client_id,
|
||||
config.camera_name,
|
||||
before,
|
||||
after,
|
||||
),
|
||||
getRecordingsSummary(this.hass, config.client_id, config.camera_name),
|
||||
]);
|
||||
results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] });
|
||||
} catch (e) {}
|
||||
};
|
||||
const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])];
|
||||
await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera))));
|
||||
|
||||
const children = this._createRecordingChildren(results, time, !camera);
|
||||
if (!children.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let childIndex = 0;
|
||||
if (camera) {
|
||||
childIndex = children.findIndex(
|
||||
(child) =>
|
||||
child.frigate?.recording &&
|
||||
child.frigate.recording.start_time * 1000 === after.getTime(),
|
||||
);
|
||||
if (childIndex < 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'event',
|
||||
target: createEventParentForChildren(localize('common.recordings'), children),
|
||||
childIndex: childIndex,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the range is in the process of being changed.
|
||||
* @param properties
|
||||
*/
|
||||
protected _timelineRangeChangeHandler(
|
||||
properties: TimelineEventPropertiesResult,
|
||||
): void {
|
||||
if (properties.event) {
|
||||
// When a human changes the range, an event will be set.
|
||||
this._wasDragged = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the timeline is clicked.
|
||||
* @param properties The properties of the timeline click event.
|
||||
*/
|
||||
protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void {
|
||||
if (properties.what === 'item') {
|
||||
if (properties.what && ['item', 'background'].includes(properties.what)) {
|
||||
// Prevent interaction with items on the timeline from activating card
|
||||
// wide actions.
|
||||
stopEventFromActivatingCardWideActions(properties.event);
|
||||
}
|
||||
|
||||
if (!this._wasDragged && properties.what) {
|
||||
if (['background', 'group-label'].includes(properties.what)) {
|
||||
this._changeViewToRecording(properties.time, String(properties.group));
|
||||
} else if (properties.what === 'axis') {
|
||||
this._changeViewToRecording(properties.time);
|
||||
}
|
||||
}
|
||||
|
||||
this._wasDragged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -468,14 +761,15 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
return;
|
||||
}
|
||||
if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
|
||||
this._events
|
||||
.fetchEventsIfNecessary(
|
||||
this._data
|
||||
.fetchIfNecessary(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.timelineConfig.media,
|
||||
properties.start,
|
||||
properties.end,
|
||||
this.timelineConfig.show_recordings,
|
||||
)
|
||||
.then(() => {
|
||||
if (this._timeline) {
|
||||
@@ -507,7 +801,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
const childIndex = data.items.length
|
||||
? this.view.target.children.findIndex(
|
||||
(child) => child.frigate?.event.id === data.items[0],
|
||||
(child) => child.frigate?.event?.id === data.items[0],
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -560,8 +854,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
const selected = this._timeline.getSelection();
|
||||
let childIndex = -1;
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
this._events.dataset.get({ order: sortEvent }).forEach((item) => {
|
||||
if (item.source) {
|
||||
this._data.dataset.get({ order: sortEvent }).forEach((item) => {
|
||||
if (item.event && item.source) {
|
||||
children.push(item.source);
|
||||
if (selected.includes(item.event.id)) {
|
||||
childIndex = children.length - 1;
|
||||
@@ -572,9 +866,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = createEventParentForChildren('Timeline events', children);
|
||||
return {
|
||||
target: target,
|
||||
target: createEventParentForChildren('Timeline events', children),
|
||||
childIndex: childIndex < 0 ? null : childIndex,
|
||||
};
|
||||
}
|
||||
@@ -695,12 +988,14 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
// Never include the target media in a cluster, and never group
|
||||
// different object types together (e.g. person and car).
|
||||
return (
|
||||
[first.type, second.type].every((type) => type !== 'background') &&
|
||||
first.type === second.type &&
|
||||
!!first.id &&
|
||||
first.id !== this.view?.media?.frigate?.event?.id &&
|
||||
!!second.id &&
|
||||
second.id != this.view?.media?.frigate?.event?.id &&
|
||||
(<FrigateCardTimelineItem>first).event.label ===
|
||||
(<FrigateCardTimelineItem>second).event.label
|
||||
(<FrigateCardTimelineItem>first).event?.label ===
|
||||
(<FrigateCardTimelineItem>second).event?.label
|
||||
);
|
||||
},
|
||||
}
|
||||
@@ -750,13 +1045,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
* Update the timeline from the view object.
|
||||
*/
|
||||
protected async _updateTimelineFromView(): Promise<void> {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameras ||
|
||||
!this.view ||
|
||||
!this._timeline ||
|
||||
!this.timelineConfig
|
||||
) {
|
||||
if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -765,15 +1054,20 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
? this._getStartEndFromEvent(event)
|
||||
: this._getStartEnd();
|
||||
|
||||
await this._events.fetchEventsIfNecessary(
|
||||
await this._data.fetchIfNecessary(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.timelineConfig.media,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
this.timelineConfig.show_recordings,
|
||||
);
|
||||
|
||||
if (!this._timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._timeline.setSelection(event ? [event.id] : [], {
|
||||
focus: false,
|
||||
animation: {
|
||||
@@ -807,9 +1101,9 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
// 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.
|
||||
const item = this._events.dataset.get(event.id);
|
||||
const item = this._data.dataset.get(event.id);
|
||||
if (item) {
|
||||
this._events.dataset.updateOnly(item);
|
||||
this._data.dataset.updateOnly(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -825,7 +1119,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
// -> New view dispatched (to load thumbnails into outer carousel).
|
||||
// -> New view received ... [loop]
|
||||
const currentContext = this.view.context as TimelineViewContext | null;
|
||||
if (currentContext?.dateFetch !== this._events.lastFetchDate) {
|
||||
if (currentContext?.dateFetch !== this._data.lastFetchDate) {
|
||||
const thumbnails = this._generateThumbnails();
|
||||
this.view
|
||||
?.evolve({
|
||||
@@ -851,8 +1145,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
} else if (currentContext?.window) {
|
||||
newContext.window = currentContext.window;
|
||||
}
|
||||
if (this._events.lastFetchDate) {
|
||||
newContext.dateFetch = this._events.lastFetchDate;
|
||||
if (this._data.lastFetchDate) {
|
||||
newContext.dateFetch = this._data.lastFetchDate;
|
||||
}
|
||||
return newContext || null;
|
||||
}
|
||||
@@ -865,7 +1159,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('cameras')) {
|
||||
this._events.clear();
|
||||
this._data.clear();
|
||||
this._timeline?.destroy();
|
||||
this._timeline = undefined;
|
||||
}
|
||||
@@ -888,14 +1182,14 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
this._timeline = new Timeline(
|
||||
this._refTimeline.value,
|
||||
this._events.dataset,
|
||||
this._data.dataset,
|
||||
groups,
|
||||
options,
|
||||
);
|
||||
this._timeline.on('select', this._timelineSelectHandler.bind(this));
|
||||
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
|
||||
this._timeline.on('click', this._timelineClickHandler.bind(this));
|
||||
this._timeline.on('doubleclick', this._timelineClickHandler.bind(this));
|
||||
this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user