diff --git a/src/components/gallery.ts b/src/components/gallery.ts
index 8f15f4f5..5f4d8137 100644
--- a/src/components/gallery.ts
+++ b/src/components/gallery.ts
@@ -222,7 +222,7 @@ export class FrigateCardGalleryCore extends LitElement {
/>${child.frigate?.event?.retain_indefinitely ? html`` : ``}`
: ``}
diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts
index fdb03974..3f213bd1 100644
--- a/src/components/surround-thumbnails.ts
+++ b/src/components/surround-thumbnails.ts
@@ -20,11 +20,6 @@ import './surround.js';
import surroundThumbnailsStyle from '../scss/surround.scss';
-interface FrigateCardThumbnailsSet {
- target?: FrigateBrowseMediaSource;
- childIndex?: number;
-}
-
@customElement('frigate-card-surround-thumbnails')
export class FrigateCardSurround extends LitElement {
@property({ attribute: false })
@@ -42,15 +37,10 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false })
protected browseMediaParams?: BrowseMediaQueryParameters;
- @state()
- protected _thumbnailTarget?: FrigateBrowseMediaSource;
-
- @state()
- protected _thumbnailSelected?: number | null;
-
// A task to await the load of the WebRTC component.
protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [
this.hass,
+ this.view,
this.browseMediaParams,
]);
@@ -59,15 +49,17 @@ export class FrigateCardSurround extends LitElement {
* @param param Task parameters.
* @returns
*/
- protected async _fetchMedia([hass, browseMediaParams]: (
+ protected async _fetchMedia([hass, view, browseMediaParams]: (
| (HomeAssistant & ExtendedHomeAssistant)
+ | Readonly
| BrowseMediaQueryParameters
| undefined
)[]): Promise {
hass = hass as HomeAssistant & ExtendedHomeAssistant;
+ view = view as Readonly;
browseMediaParams = browseMediaParams as BrowseMediaQueryParameters;
- if (!hass || !browseMediaParams) {
+ if (!hass || !view || !browseMediaParams) {
return;
}
let parent: FrigateBrowseMediaSource | null;
@@ -77,8 +69,13 @@ export class FrigateCardSurround extends LitElement {
return dispatchErrorMessageEvent(this, (e as Error).message);
}
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
- this._thumbnailTarget = parent;
- this._thumbnailSelected = null;
+ this.view
+ ?.evolve({
+ ...(this.targetView && { view: this.targetView }),
+ target: parent,
+ childIndex: undefined,
+ })
+ .dispatchChangeEvent(this);
}
}
@@ -92,12 +89,6 @@ export class FrigateCardSurround extends LitElement {
}
return html` ) => {
- if (ev.detail.target) {
- this._thumbnailTarget = ev.detail.target;
- }
- this._thumbnailSelected = ev.detail.childIndex;
- }}
@frigate-card:thumbnails:open=${(ev: CustomEvent) => {
if (this.config && ['left', 'right'].includes(this.config.mode)) {
// Protects encapsulation: Catches the request to view thumbnails and
@@ -110,13 +101,23 @@ export class FrigateCardSurround extends LitElement {
});
}
}}
+ @frigate-card:change-view=${(ev) => {
+ // Close the drawer if the carousel or thumbnail requests a view change
+ // (e.g. playing the clip, or viewing something on the timeline).
+ if (this.config && ['left', 'right'].includes(this.config.mode)) {
+ dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:close', {
+ drawer: this.config.mode,
+ });
+ }
+ }}
>
${this.config?.mode !== 'none'
? html` ) => {
this.view
?.evolve({
diff --git a/src/components/surround.ts b/src/components/surround.ts
index e929b95e..586f5191 100644
--- a/src/components/surround.ts
+++ b/src/components/surround.ts
@@ -16,14 +16,15 @@ interface FrigateCardDrawerOpen {
export class FrigateCardSurround extends LitElement {
protected _refDrawerLeft: Ref = createRef();
protected _refDrawerRight: Ref = createRef();
- protected _boundDrawerOpenHandler = this._drawerOpen.bind(this);
+ protected _boundDrawerHandler = this._drawerHandler.bind(this);
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
- this.addEventListener('frigate-card:drawer:open', this._boundDrawerOpenHandler);
+ this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
+ this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
}
/**
@@ -31,15 +32,17 @@ export class FrigateCardSurround extends LitElement {
*/
disconnectedCallback(): void {
super.disconnectedCallback();
- this.removeEventListener('frigate-card:drawer:open', this._boundDrawerOpenHandler);
+ this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
+ this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
}
- protected _drawerOpen(ev: Event) {
+ protected _drawerHandler(ev: Event) {
const drawer = (ev as CustomEvent).detail.drawer;
+ const open = ev.type.endsWith(':open');
if (drawer === 'left' && this._refDrawerLeft.value) {
- this._refDrawerLeft.value.open = true;
+ this._refDrawerLeft.value.open = open;
} else if (drawer === 'right' && this._refDrawerRight.value) {
- this._refDrawerRight.value.open = true;
+ this._refDrawerRight.value.open = open;
}
}
diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts
index 6c28d63c..9be59991 100644
--- a/src/components/thumbnail-carousel.ts
+++ b/src/components/thumbnail-carousel.ts
@@ -4,9 +4,14 @@ import { EmblaOptionsType } from 'embla-carousel';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
+import { isEqual } from 'lodash-es';
-import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
+import type {
+ FrigateBrowseMediaSource,
+ ThumbnailsControlConfig,
+} from '../types.js';
import { FrigateCardCarousel } from './carousel.js';
+import { View } from '../view.js';
import {
contentsChanged,
dispatchFrigateCardEvent,
@@ -25,6 +30,11 @@ export interface ThumbnailCarouselTap {
@customElement('frigate-card-thumbnail-carousel')
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
+ @property({ attribute: false })
+ protected view?: Readonly;
+
+ // Use contentsChanged here to avoid the carousel rebuilding and resetting in
+ // front of the user, unless the contents have actually changed.
@property({ attribute: false, hasChanged: contentsChanged })
public target?: FrigateBrowseMediaSource;
@@ -62,7 +72,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
/**
* Handle gallery resize.
*/
- protected _resizeHandler(): void {
+ protected _resizeHandler(): void {
if (this._carousel) {
this._carousel.reInit();
}
@@ -71,7 +81,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
/**
* Component connected callback.
*/
- connectedCallback(): void {
+ connectedCallback(): void {
super.connectedCallback();
this._resizeObserver.observe(this);
}
@@ -145,12 +155,11 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
childIndex: number,
slideIndex: number,
): TemplateResult | void {
- if (!parent.children || !parent.children.length) {
- return;
- }
-
- const mediaToRender = parent.children[childIndex];
- if (!BrowseMediaUtil.isTrueMedia(mediaToRender)) {
+ if (
+ !parent.children ||
+ !parent.children.length ||
+ !BrowseMediaUtil.isTrueMedia(parent.children[childIndex])
+ ) {
return;
}
@@ -160,7 +169,9 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
};
return html` ;
+
+ @property({ attribute: false })
+ public target?: FrigateBrowseMediaSource;
+
+ @property({ attribute: false })
+ public childIndex?: number;
@property({ attribute: true, type: Boolean, reflect: true })
public details = false;
@@ -26,21 +37,25 @@ export class FrigateCardThumbnail extends LitElement {
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
- if (!this.media || !this.media.thumbnail) {
+ if (!this.target || !this.target.children || !this.childIndex) {
return;
}
- const event = this.media.frigate?.event;
- return html`
-
${event?.retain_indefinitely
? html` `
: ``}
${this.details && event
@@ -65,7 +80,21 @@ export class FrigateCardThumbnail extends LitElement {
`
: html``}
- `;
+ {
+ stopEventFromActivatingCardWideActions(ev);
+ this.view
+ ?.evolve({
+ view: 'timeline',
+ target: this.target,
+ childIndex: this.childIndex,
+ })
+ .dispatchChangeEvent(this);
+ }}
+ >`;
}
/**
diff --git a/src/components/timeline.ts b/src/components/timeline.ts
index f8376a75..f13bfedb 100644
--- a/src/components/timeline.ts
+++ b/src/components/timeline.ts
@@ -1,3 +1,10 @@
+// TODO: Clips vs snapshots: Should be able to navigate from snapshots view and it should just work.
+// TODO: Remove HACK in view.ts on clips
+// TODO: Hover over an event should show something useful.
+// TODO: Periodically refetch events.
+// TODO: Search for TODOs and logging statements.
+// TODO: Allow download of selected event in timeline.
+
import {
CSSResultGroup,
LitElement,
@@ -7,16 +14,21 @@ import {
PropertyValues,
} from 'lit';
import { DataSet } from 'vis-data/esnext';
-import { HomeAssistant } from 'custom-card-helpers';
import {
DataGroupCollectionType,
+ IdType,
Timeline,
+ TimelineItem,
TimelineOptions,
TimelineOptionsCluster,
+ TimelineWindow,
} from 'vis-timeline/esnext';
+import { HomeAssistant } from 'custom-card-helpers';
import { classMap } from 'lit/directives/class-map.js';
-import { customElement, property, state } from 'lit/decorators.js';
+import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
+import { add, fromUnixTime, sub } from 'date-fns';
+import { isEqual } from 'lodash-es';
import { BrowseMediaUtil } from '../browse-media-util';
import {
@@ -27,10 +39,10 @@ import {
MEDIA_TYPE_VIDEO,
MEDIA_CLASS_VIDEO,
TimelineConfig,
+ FrigateEvent,
} from '../types';
-import { View } from '../view';
+import { View, ViewContext } from '../view';
import {
- contentsChanged,
dispatchErrorMessageEvent,
dispatchFrigateCardEvent,
getCameraTitle,
@@ -45,18 +57,26 @@ interface FrigateCardGroupData {
id: string;
content: string;
}
-interface FrigateCardTimelineData {
- id: string;
- content: string;
- start: number;
- end?: number;
+interface FrigateCardTimelineItem extends TimelineItem {
source: FrigateBrowseMediaSource;
}
-class TimelineEventManager {
- protected _dataset = new DataSet();
+interface TimelineViewContext extends ViewContext {
+ window: TimelineWindow;
+}
- protected _contentCallback?: (FrigateBrowseMediaSource) => string;
+/**
+ * A manager to maintain/fetch timeline events.
+ */
+class TimelineEventManager {
+ protected _dataset = new DataSet();
+
+ // The earliest date managed.
+ protected _dateStart?: Date;
+
+ // The latest date managed.
+ protected _dateEnd?: Date;
+ protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
constructor(params?: {
contentCallback?: (source: FrigateBrowseMediaSource) => string;
@@ -64,20 +84,35 @@ class TimelineEventManager {
this._contentCallback = params?.contentCallback;
}
- get dataset(): DataSet {
+ /**
+ * Retrieve the underlying dataset.
+ */
+ get dataset(): DataSet {
return this._dataset;
}
+ /**
+ * Determine if the dataset is empty.
+ * @returns
+ */
public isEmpty(): boolean {
return this._dataset.length === 0;
}
+ /**
+ * Clear the dataset.
+ */
public clear(): void {
this._dataset.clear();
}
+ /**
+ * Add a FrigateBrowseMediaSource object to the managed timeline.
+ * @param camera The id the camera this object is from.
+ * @param target The FrigateBrowseMediaSource to add.
+ */
protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void {
- const items: FrigateCardTimelineData[] = [];
+ const items: FrigateCardTimelineItem[] = [];
target.children?.forEach((child) => {
if (child.frigate) {
const item = {
@@ -99,19 +134,68 @@ class TimelineEventManager {
this._dataset.update(items);
}
- public async fetchEvents(
- node: HTMLElement,
+ /**
+ * Determine if the timeline has coverage for a given range of dates.
+ * @param start The start of the date range.
+ * @param end An optional end of the date range.
+ * @returns
+ */
+ public hasCoverage(start: Date, end?: Date): boolean {
+ return (
+ !!this._dateStart &&
+ start >= this._dateStart &&
+ (!end || (!!this._dateEnd && end <= this._dateEnd))
+ );
+ }
+
+ /**
+ * Fetch events if no coverage in given range.
+ * @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.
+ * @returns `true` if events were fetched, `false` otherwise.
+ */
+ public async fetchEventsIfNecessary(
+ element: HTMLElement,
+ hass: HomeAssistant & ExtendedHomeAssistant,
+ cameras: Map,
+ start: Date,
+ end: Date,
+ ): Promise {
+ if (!this.hasCoverage(start, end)) {
+ await this._fetchEvents(element, hass, cameras, start, end);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Fetch events 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 _fetchEvents(
+ element: HTMLElement,
hass: HomeAssistant & ExtendedHomeAssistant,
cameras: Map,
start: Date,
end: Date,
): Promise {
- console.info(`fetchEvents: ${start} -> ${end}`);
+ if (!this._dateStart || start < this._dateStart) {
+ this._dateStart = start;
+ }
+ if (!this._dateEnd || end > this._dateEnd) {
+ this._dateEnd = end;
+ }
- // const output = new Map();
const fetchCameraEvents = async (camera: string): Promise => {
const cameraConfig = cameras.get(camera);
- if (!cameraConfig) {
+ if (!cameraConfig || !this._dateStart || !this._dateEnd) {
return;
}
const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters(
@@ -127,11 +211,17 @@ class TimelineEventManager {
camera,
await BrowseMediaUtil.browseMediaQuery(hass, {
...browseMediaQueryParameters,
+
+ // 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,
unlimited: true,
}),
);
} catch (e) {
- return dispatchErrorMessageEvent(node, (e as Error).message);
+ return dispatchErrorMessageEvent(element, (e as Error).message);
}
};
@@ -197,35 +287,24 @@ export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false })
protected cameras?: Map;
- /**
- * Set the timeline configuration.
- */
- set timelineConfig(timelineConfig: TimelineConfig) {
- this._timelineConfig = timelineConfig;
- this._setOptions();
- }
-
- @state()
- protected _timelineConfig?: TimelineConfig;
-
- @state({ hasChanged: contentsChanged })
- protected _timelineOptions?: TimelineOptions;
-
- protected _timelineRef: Ref = createRef();
- protected _timeline?: Timeline;
+ @property({ attribute: false })
+ protected timelineConfig?: TimelineConfig;
protected _events = new TimelineEventManager();
+ protected _refTimeline: Ref = createRef();
+ protected _thumbnails?: FrigateBrowseMediaSource;
+ protected _timeline?: Timeline;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
- if (!this.hass || !this.view || !this._timelineConfig) {
+ if (!this.hass || !this.view || !this.timelineConfig) {
return;
}
- const thumbnailsConfig = this._timelineConfig.controls.thumbnails;
+ const thumbnailsConfig = this.timelineConfig.controls.thumbnails;
const timelineClasses = {
timeline: true,
'left-margin': thumbnailsConfig.mode === 'left',
@@ -234,7 +313,7 @@ export class FrigateCardTimelineCore extends LitElement {
return html``;
}
@@ -251,74 +330,87 @@ export class FrigateCardTimelineCore extends LitElement {
console.info(
`Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`,
);
- if (this.hass && this.cameras) {
- // This is not performant in that it refetches all events in the time
- // range, when some/all may already be fetched. A more optimal approach
- // would be to only fetch events in time windows that haven't already been
- // fetched PLUS events that did not previously have an end_time. That's
- // not trivial to implement, and it's not yet clear it's worth the extra
- // complexity.
+ if (this.hass && this.cameras && this._timeline) {
this._events
- .fetchEvents(this, this.hass, this.cameras, properties.start, properties.end)
- .then(() => {
- this._updateThumbnails();
+ .fetchEventsIfNecessary(
+ this,
+ this.hass,
+ this.cameras,
+ properties.start,
+ properties.end,
+ )
+ .then((fetched: boolean) => {
+ if (fetched) {
+ this._generateThumbnails();
+ }
});
+
+ // Update the view to ensure that future view changes do not cause a
+ // scroll.
+ this.view
+ ?.evolve({
+ context: {
+ window: this._timeline.getWindow(),
+ },
+ })
+ .dispatchChangeEvent(this);
}
}
/**
* Called when an object on the timeline is selected.
- * @param _data The data about the selection.
+ * @param data The data about the selection.
* @returns
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- protected _timelineSelectHandler(_data: { items: string[]; event: Event }): void {
- this._updateThumbnails();
- dispatchFrigateCardEvent(this, 'thumbnails:open');
+ protected _timelineSelectHandler(data: { items: string[]; event: Event }): void {
+ if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) {
+ return;
+ }
+ const childIndex = this._findThumbnailIndex(data.items[0]);
+ if (childIndex >= 0) {
+ this.view
+ ?.evolve({
+ target: this._thumbnails,
+ childIndex: childIndex,
+ })
+ .dispatchChangeEvent(this);
+ dispatchFrigateCardEvent(this, 'thumbnails:open');
+ }
}
- protected _updateThumbnails(): void {
+ /**
+ * Find the index of the given item in the thumbnails.
+ * @param id
+ * @returns The index of the item, or -1 if not found.
+ */
+ public _findThumbnailIndex(id: IdType | IdType[]): number {
+ if (!this._thumbnails || !this._thumbnails.children) {
+ return -1;
+ }
+ id = Array.isArray(id) ? id[0] : id;
+ return this._thumbnails.children.findIndex((child) => child.media_content_id === id);
+ }
+
+ /**
+ * Regenerate the thumbnails from the timeline events.
+ * @returns
+ */
+ protected _generateThumbnails(): void {
if (!this._timeline) {
return;
}
- const selected = this._timeline?.getSelection();
-
- const timelineWindow = this._timeline.getWindow();
- const start = timelineWindow.start.getTime();
- const end = timelineWindow.end.getTime();
-
- const children: FrigateBrowseMediaSource[] = [];
- let childIndex: number | null = null;
-
- // Fetch all the events that match the extent of the visible window (cannot
- // use getVisibleItems() since it does not return clustered items).
- this._events.dataset
- .get({
- filter: (item) =>
- // Start within the window.
- (item.start >= start && item.start <= end) ||
- // End within the window.
- (!!item.end && item.end >= start && item.end <= end) ||
- // Item lifetime extends past the window
- (item.start <= start && !!item.end && item.end >= end),
- order: 'start',
- })
- .forEach((item) => {
- if (item.source.can_play) {
- if (childIndex === null && selected.includes(item.id)) {
- childIndex = children.length;
- }
- children.push(item.source);
- }
- });
-
+ const children: FrigateBrowseMediaSource[] = this._events.dataset
+ .get()
+ .filter((item) => BrowseMediaUtil.isTrueMedia(item.source))
+ .map((item) => item.source);
if (!children.length) {
return;
}
const target = {
- title: `Timeline ${start} - ${end}`,
+ title: `Timeline events`,
media_class: MEDIA_CLASS_PLAYLIST,
media_content_type: MEDIA_TYPE_VIDEO,
media_content_id: '',
@@ -329,10 +421,16 @@ export class FrigateCardTimelineCore extends LitElement {
children: children,
};
- dispatchFrigateCardEvent(this, 'thumbnails:set', {
- target: target,
- childIndex: childIndex ?? undefined,
- });
+ this._thumbnails = target;
+ const childIndex = this._findThumbnailIndex(this._timeline.getSelection());
+
+ // Update the thumbnail carousel with the regenerated thumbnails.
+ this.view
+ ?.evolve({
+ target: this._thumbnails,
+ childIndex: childIndex < 0 ? undefined : childIndex,
+ })
+ .dispatchChangeEvent(this);
}
/**
@@ -350,39 +448,97 @@ export class FrigateCardTimelineCore extends LitElement {
return new DataSet(groups);
}
+ /**
+ * Given an event get an appropriate start/end time window around the event.
+ * @param event The FrigateEvent to consider.
+ * @returns A tuple of start/end date.
+ */
+ protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] {
+ const one_hour = { hours: 1 };
+ const start = sub(fromUnixTime(event.start_time), one_hour);
+ let end: Date;
+
+ if (event.end_time) {
+ end = add(fromUnixTime(event.end_time), one_hour);
+ } else {
+ end = add(start, one_hour);
+ }
+ return [start, end];
+ }
+
+ /**
+ * Get desired timeline start/end time.
+ * @returns A tuple of start/end date.
+ */
+ protected _getStartEnd(): [Date, Date] {
+ const event = this.view?.target?.frigate?.event;
+ if (event) {
+ return this._getStartEndFromEvent(event);
+ }
+ const one_hour = { hours: 1 };
+ const end = new Date();
+ const start = sub(end, one_hour);
+ return [start, end];
+ }
+
+ /**
+ * Determine if the timeline should use clustering.
+ * @returns `true` if the timeline should cluster, `false` otherwise.
+ */
+ protected _isClustering(): boolean {
+ return (
+ !!this.timelineConfig?.clustering_threshold &&
+ this.timelineConfig.clustering_threshold > 0
+ );
+ }
+
/**
* Handle timeline resize.
*/
- protected _setOptions(): void {
- if (!this._timelineConfig) {
+ protected _getOptions(): TimelineOptions | void {
+ if (!this.timelineConfig) {
return;
}
+ const [start, end] = this._getStartEnd();
+
// Configuration for the Timeline, see:
// https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options
- this._timelineOptions = {
- cluster:
- this._timelineConfig.clustering_threshold > 0
- ? {
- showStipes: true,
- // It would be better to automatically calculate `maxItems` from the
- // rendered height of the timeline (or group within the timeline) so
- // as to not waste vertical space (e.g. after the user changes to
- // fullscreen mode). Unfortunately this is not easy to do, as we
- // don't know the height of the timeline until after it renders --
- // and if we adjust `maxItems` then we can get into an infinite
- // resize loop. Adjusting the `maxItems` of a timeline, after it's
- // created, also does not appear to work as expected.
- maxItems: this._timelineConfig.clustering_threshold,
- }
- : (false as TimelineOptionsCluster),
+ return {
+ cluster: this._isClustering()
+ ? {
+ showStipes: true,
+ // It would be better to automatically calculate `maxItems` from the
+ // rendered height of the timeline (or group within the timeline) so
+ // as to not waste vertical space (e.g. after the user changes to
+ // fullscreen mode). Unfortunately this is not easy to do, as we
+ // don't know the height of the timeline until after it renders --
+ // and if we adjust `maxItems` then we can get into an infinite
+ // resize loop. Adjusting the `maxItems` of a timeline, after it's
+ // created, also does not appear to work as expected.
+ maxItems: this.timelineConfig.clustering_threshold,
+
+ clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
+ // Never include the target media in a cluster, and never group
+ // different object types together (e.g. person and car).
+ return (
+ !!first.id &&
+ first.id !== this.view?.media?.media_content_id &&
+ !!second.id &&
+ second.id != this.view?.media?.media_content_id &&
+ (first).source.frigate?.event.label ===
+ (second).source.frigate?.event.label
+ );
+ },
+ }
+ : (false as TimelineOptionsCluster),
minHeight: '100%',
maxHeight: '100%',
zoomMax: 31 * 24 * 60 * 60 * 1000,
zoomMin: 1 * 1000,
selectable: true,
- start: this._getYesterday(),
- end: this._getToday(),
+ start: start,
+ end: end,
groupHeightMode: 'fixed',
xss: {
disabled: false,
@@ -402,24 +558,6 @@ export class FrigateCardTimelineCore extends LitElement {
};
}
- /**
- * Get today date object.
- * @returns A date object for today.
- */
- protected _getToday(): Date {
- return new Date();
- }
-
- /**
- * Get yesterday date object.
- * @returns A date object for yesterday.
- */
- protected _getYesterday(): Date {
- const yesterday = new Date();
- yesterday.setDate(this._getToday().getDate() - 1);
- return yesterday;
- }
-
/**
* Determine if the component should be updated.
* @param _changedProps The changed properties.
@@ -431,25 +569,63 @@ export class FrigateCardTimelineCore extends LitElement {
}
/**
- * Called on the first update.
- * @param changedProps The changed properties.
+ * Update the timeline from the view object.
*/
- protected firstUpdated(changedProps: PropertyValues): void {
- super.firstUpdated(changedProps);
+ protected async _updateTimelineFromView(): Promise {
+ const event = this.view?.media?.frigate?.event;
+ const id = this.view?.media?.media_content_id;
- if (changedProps.has('cameras')) {
- this._events.clear();
+ if (!this.hass || !this.cameras || !this.view || !event || !id || !this._timeline) {
+ return;
}
- if (this._events.isEmpty() && this.hass && this.cameras) {
- // Fetch an initial 1-day worth of events.
- this._events.fetchEvents(
- this,
- this.hass,
- this.cameras,
- this._getToday(),
- this._getYesterday(),
+ const [eventWindowStart, eventWindowEnd] = this._getStartEndFromEvent(event);
+ await this._events.fetchEventsIfNecessary(
+ this,
+ this.hass,
+ this.cameras,
+ eventWindowStart,
+ eventWindowEnd,
+ );
+
+ const eventStart = new Date(event.start_time * 1000);
+ const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0;
+
+ this._timeline.setSelection([id], {
+ focus: false,
+ animation: {
+ animation: false,
+ zoom: false,
+ },
+ });
+
+ const timelineWindow = this._timeline.getWindow();
+ const context = this.view.context
+ ? (this.view.context as TimelineViewContext)
+ : undefined;
+
+ if (context && !isEqual(context.window, timelineWindow)) {
+ console.info(
+ `Setting window from context (${context.window.start} -> ${context.window.end}`,
);
+ this._timeline.setWindow(context.window.start, context.window.end);
+ } else if (
+ eventStart < timelineWindow.start ||
+ eventStart > timelineWindow.end ||
+ (eventEnd && (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end))
+ ) {
+ console.info(`Setting window from event ${eventWindowStart} -> ${eventWindowEnd}`);
+ this._timeline.setWindow(eventWindowStart, eventWindowEnd);
+ }
+
+ if (this._isClustering()) {
+ // 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(id);
+ if (item) {
+ this._events.dataset.updateOnly(item);
+ }
}
}
@@ -460,20 +636,30 @@ export class FrigateCardTimelineCore extends LitElement {
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
- if (this._timelineRef.value) {
- if (this._timeline) {
- this._timeline.destroy();
- this._timeline = undefined;
- }
+ if (changedProperties.has('cameras')) {
+ this._events.clear();
+ this._timeline?.destroy();
+ this._timeline = undefined;
+ }
- this._timeline = new Timeline(
- this._timelineRef.value,
- this._events.dataset,
- this._getGroups(),
- this._timelineOptions,
- );
- this._timeline.on('select', this._timelineSelectHandler.bind(this));
- this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
+ const options = this._getOptions();
+ if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) {
+ if (this._timeline) {
+ // TODO this._timeline.setOptions(options);
+ } else {
+ this._timeline = new Timeline(
+ this._refTimeline.value,
+ this._events.dataset,
+ this._getGroups(),
+ options,
+ );
+ this._timeline.on('select', this._timelineSelectHandler.bind(this));
+ this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
+ }
+ }
+
+ if (changedProperties.has('view')) {
+ this._updateTimelineFromView();
}
}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index f781ebb2..75d5f888 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -95,6 +95,7 @@ export class FrigateCardViewer extends LitElement {
.hass=${this.hass}
.view=${this.view}
.config=${this.viewerConfig.controls.thumbnails}
+ .browseMediaParams=${browseMediaQueryParameters}
>
) => {
- // When a slide is selected in the viewer carousel, send a new event
- // from the same source asking for the thumbnails to be updated.
- dispatchFrigateCardEvent(ev.composedPath()[0], 'thumbnails:set', {
- childIndex: ev.detail.index,
- });
- }}
>
`;
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index d10f5c85..cc5092c9 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -213,8 +213,11 @@
"event": {
"start": "Start",
"duration": "Duration",
- "in_progress": "In Progress",
- "retain_indefinitely": "Event will be indefinitely retained"
+ "in_progress": "In Progress"
+ },
+ "thumbnail": {
+ "retain_indefinitely": "Event will be indefinitely retained",
+ "timeline": "See event in timeline"
},
"error": {
"empty_response": "Received empty response from Home Assistant for request",
diff --git a/src/scss/favorite.scss b/src/scss/favorite.scss
index c89adc2b..993753c4 100644
--- a/src/scss/favorite.scss
+++ b/src/scss/favorite.scss
@@ -2,4 +2,11 @@ ha-icon.favorite {
position: absolute;
color: var(--primary-color);
padding: 2px;
-}
\ No newline at end of file
+}
+
+ha-icon.timeline {
+ position: absolute;
+ color: var(--primary-color);
+ padding: 2px;
+ right: 0px;
+}
diff --git a/src/types.ts b/src/types.ts
index 3cd65d15..7d41e039 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -335,8 +335,8 @@ export type MenuStateIcon = z.infer;
const menuSubmenuItemSchema = elementsBaseSchema.extend({
entity: z.string().optional(),
icon: z.string().optional(),
- state_color: z.boolean().default(true),
- selected: z.boolean().default(false),
+ state_color: z.boolean().default(true).optional(),
+ selected: z.boolean().default(false).optional(),
});
export type MenuSubmenuItem = z.infer;
diff --git a/src/view.ts b/src/view.ts
index f16c9341..e4030e18 100644
--- a/src/view.ts
+++ b/src/view.ts
@@ -1,12 +1,16 @@
import type { FrigateBrowseMediaSource, FrigateCardView } from './types.js';
import { dispatchFrigateCardEvent } from './common.js';
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface ViewContext {}
+
export interface ViewEvolveParameters {
view?: FrigateCardView;
camera?: string;
target?: FrigateBrowseMediaSource;
childIndex?: number;
previous?: View;
+ context?: ViewContext;
}
export interface ViewParameters extends ViewEvolveParameters {
@@ -20,6 +24,7 @@ export class View {
target?: FrigateBrowseMediaSource;
childIndex?: number;
previous?: View;
+ context?: ViewContext;
constructor(params: ViewParameters) {
this.view = params?.view;
@@ -27,6 +32,7 @@ export class View {
this.target = params?.target;
this.childIndex = params?.childIndex;
this.previous = params?.previous;
+ this.context = params?.context;
}
/**
@@ -38,7 +44,8 @@ export class View {
camera: this.camera,
target: this.target,
childIndex: this.childIndex,
- previous: this.previous
+ previous: this.previous,
+ context: this.context,
});
}
@@ -54,7 +61,8 @@ export class View {
target: params.target ?? this.target,
childIndex: params.childIndex ?? this.childIndex,
previous: params.previous ?? this.previous,
- })
+ context: params.context ?? this.context,
+ });
}
/**
@@ -82,16 +90,14 @@ export class View {
* Determine if a view is for the media viewer.
*/
public isViewerView(): boolean {
- return ['clip', 'snapshot'].includes(
- this.view,
- );
+ return ['clip', 'snapshot'].includes(this.view);
}
/**
* Determine if a view is related to a clip or clips.
*/
public isClipRelatedView(): boolean {
- // TODO HACK HACK HACK
+ // TODO HACK HACK HACK
return ['clip', 'clips', 'timeline'].includes(this.view);
}