Maintain the selected range in the view.

This commit is contained in:
Dermot Duffy
2022-04-02 09:26:34 -07:00
parent a8770ec61a
commit a94f53f8da
11 changed files with 464 additions and 224 deletions
+1 -1
View File
@@ -222,7 +222,7 @@ export class FrigateCardGalleryCore extends LitElement {
/>${child.frigate?.event?.retain_indefinitely ? html`<ha-icon />${child.frigate?.event?.retain_indefinitely ? html`<ha-icon
class="favorite" class="favorite"
icon="mdi:star" icon="mdi:star"
title=${localize('event.retain_indefinitely')} title=${localize('thumbnail.retain_indefinitely')}
/>` : ``}` />` : ``}`
: ``} : ``}
</div> </div>
+24 -23
View File
@@ -20,11 +20,6 @@ import './surround.js';
import surroundThumbnailsStyle from '../scss/surround.scss'; import surroundThumbnailsStyle from '../scss/surround.scss';
interface FrigateCardThumbnailsSet {
target?: FrigateBrowseMediaSource;
childIndex?: number;
}
@customElement('frigate-card-surround-thumbnails') @customElement('frigate-card-surround-thumbnails')
export class FrigateCardSurround extends LitElement { export class FrigateCardSurround extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -42,15 +37,10 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaParams?: BrowseMediaQueryParameters; protected browseMediaParams?: BrowseMediaQueryParameters;
@state()
protected _thumbnailTarget?: FrigateBrowseMediaSource;
@state()
protected _thumbnailSelected?: number | null;
// A task to await the load of the WebRTC component. // A task to await the load of the WebRTC component.
protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [ protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [
this.hass, this.hass,
this.view,
this.browseMediaParams, this.browseMediaParams,
]); ]);
@@ -59,15 +49,17 @@ export class FrigateCardSurround extends LitElement {
* @param param Task parameters. * @param param Task parameters.
* @returns * @returns
*/ */
protected async _fetchMedia([hass, browseMediaParams]: ( protected async _fetchMedia([hass, view, browseMediaParams]: (
| (HomeAssistant & ExtendedHomeAssistant) | (HomeAssistant & ExtendedHomeAssistant)
| Readonly<View>
| BrowseMediaQueryParameters | BrowseMediaQueryParameters
| undefined | undefined
)[]): Promise<void> { )[]): Promise<void> {
hass = hass as HomeAssistant & ExtendedHomeAssistant; hass = hass as HomeAssistant & ExtendedHomeAssistant;
view = view as Readonly<View>;
browseMediaParams = browseMediaParams as BrowseMediaQueryParameters; browseMediaParams = browseMediaParams as BrowseMediaQueryParameters;
if (!hass || !browseMediaParams) { if (!hass || !view || !browseMediaParams) {
return; return;
} }
let parent: FrigateBrowseMediaSource | null; let parent: FrigateBrowseMediaSource | null;
@@ -77,8 +69,13 @@ export class FrigateCardSurround extends LitElement {
return dispatchErrorMessageEvent(this, (e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
this._thumbnailTarget = parent; this.view
this._thumbnailSelected = null; ?.evolve({
...(this.targetView && { view: this.targetView }),
target: parent,
childIndex: undefined,
})
.dispatchChangeEvent(this);
} }
} }
@@ -92,12 +89,6 @@ export class FrigateCardSurround extends LitElement {
} }
return html` <frigate-card-surround return html` <frigate-card-surround
@frigate-card:thumbnails:set=${(ev: CustomEvent<FrigateCardThumbnailsSet>) => {
if (ev.detail.target) {
this._thumbnailTarget = ev.detail.target;
}
this._thumbnailSelected = ev.detail.childIndex;
}}
@frigate-card:thumbnails:open=${(ev: CustomEvent) => { @frigate-card:thumbnails:open=${(ev: CustomEvent) => {
if (this.config && ['left', 'right'].includes(this.config.mode)) { if (this.config && ['left', 'right'].includes(this.config.mode)) {
// Protects encapsulation: Catches the request to view thumbnails and // 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' ${this.config?.mode !== 'none'
? html` <frigate-card-thumbnail-carousel ? html` <frigate-card-thumbnail-carousel
slot=${this.config.mode} slot=${this.config.mode}
.config=${this.config} .config=${this.config}
.target=${this._thumbnailTarget ?? this.view.target} .view=${this.view}
.selected=${this._thumbnailSelected ?? this.view.childIndex ?? null} .target=${this.view.target}
.selected=${this.view.childIndex ?? null}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => { @frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
this.view this.view
?.evolve({ ?.evolve({
+9 -6
View File
@@ -16,14 +16,15 @@ interface FrigateCardDrawerOpen {
export class FrigateCardSurround extends LitElement { export class FrigateCardSurround extends LitElement {
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef(); protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef();
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef(); protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef();
protected _boundDrawerOpenHandler = this._drawerOpen.bind(this); protected _boundDrawerHandler = this._drawerHandler.bind(this);
/** /**
* Component connected callback. * Component connected callback.
*/ */
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); 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 { disconnectedCallback(): void {
super.disconnectedCallback(); 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<FrigateCardDrawerOpen>).detail.drawer; const drawer = (ev as CustomEvent<FrigateCardDrawerOpen>).detail.drawer;
const open = ev.type.endsWith(':open');
if (drawer === 'left' && this._refDrawerLeft.value) { if (drawer === 'left' && this._refDrawerLeft.value) {
this._refDrawerLeft.value.open = true; this._refDrawerLeft.value.open = open;
} else if (drawer === 'right' && this._refDrawerRight.value) { } else if (drawer === 'right' && this._refDrawerRight.value) {
this._refDrawerRight.value.open = true; this._refDrawerRight.value.open = open;
} }
} }
+19 -8
View File
@@ -4,9 +4,14 @@ import { EmblaOptionsType } from 'embla-carousel';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.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 { FrigateCardCarousel } from './carousel.js';
import { View } from '../view.js';
import { import {
contentsChanged, contentsChanged,
dispatchFrigateCardEvent, dispatchFrigateCardEvent,
@@ -25,6 +30,11 @@ export interface ThumbnailCarouselTap {
@customElement('frigate-card-thumbnail-carousel') @customElement('frigate-card-thumbnail-carousel')
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
@property({ attribute: false })
protected view?: Readonly<View>;
// 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 }) @property({ attribute: false, hasChanged: contentsChanged })
public target?: FrigateBrowseMediaSource; public target?: FrigateBrowseMediaSource;
@@ -145,12 +155,11 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
childIndex: number, childIndex: number,
slideIndex: number, slideIndex: number,
): TemplateResult | void { ): TemplateResult | void {
if (!parent.children || !parent.children.length) { if (
return; !parent.children ||
} !parent.children.length ||
!BrowseMediaUtil.isTrueMedia(parent.children[childIndex])
const mediaToRender = parent.children[childIndex]; ) {
if (!BrowseMediaUtil.isTrueMedia(mediaToRender)) {
return; return;
} }
@@ -160,7 +169,9 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
}; };
return html` <frigate-card-thumbnail return html` <frigate-card-thumbnail
.media=${mediaToRender} .view=${this.view}
.target=${parent}
.childIndex=${childIndex}
?details=${this._config?.show_details} ?details=${this._config?.show_details}
thumbnail_size=${ifDefined(this._config?.size)} thumbnail_size=${ifDefined(this._config?.size)}
class="${classMap(classes)}" class="${classMap(classes)}"
+40 -11
View File
@@ -3,7 +3,12 @@ import { customElement, property } from 'lit/decorators.js';
import { format, fromUnixTime } from 'date-fns'; import { format, fromUnixTime } from 'date-fns';
import type { FrigateBrowseMediaSource } from '../types.js'; import type { FrigateBrowseMediaSource } from '../types.js';
import { getEventDurationString, prettifyFrigateName } from '../common.js'; import { View } from '../view.js';
import {
getEventDurationString,
prettifyFrigateName,
stopEventFromActivatingCardWideActions,
} from '../common.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import thumbnailStyle from '../scss/thumbnail.scss'; import thumbnailStyle from '../scss/thumbnail.scss';
@@ -11,7 +16,13 @@ import thumbnailStyle from '../scss/thumbnail.scss';
@customElement('frigate-card-thumbnail') @customElement('frigate-card-thumbnail')
export class FrigateCardThumbnail extends LitElement { export class FrigateCardThumbnail extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public media?: FrigateBrowseMediaSource; protected view?: Readonly<View>;
@property({ attribute: false })
public target?: FrigateBrowseMediaSource;
@property({ attribute: false })
public childIndex?: number;
@property({ attribute: true, type: Boolean, reflect: true }) @property({ attribute: true, type: Boolean, reflect: true })
public details = false; public details = false;
@@ -26,21 +37,25 @@ export class FrigateCardThumbnail extends LitElement {
* @returns A template to display to the user. * @returns A template to display to the user.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.media || !this.media.thumbnail) { if (!this.target || !this.target.children || !this.childIndex) {
return; return;
} }
const event = this.media.frigate?.event; const media = this.target.children[this.childIndex];
return html` if (!media.thumbnail) {
<img return;
aria-label="${this.media.title}" }
src="${this.media.thumbnail}"
title="${this.media.title}" const event = media.frigate?.event;
return html` <img
aria-label="${media.title}"
src="${media.thumbnail}"
title="${media.title}"
/> />
${event?.retain_indefinitely ${event?.retain_indefinitely
? html` <ha-icon ? html` <ha-icon
class="favorite" class="favorite"
icon="mdi:star" icon="mdi:star"
title=${localize('event.retain_indefinitely')} title=${localize('thumbnail.retain_indefinitely')}
/>` />`
: ``} : ``}
${this.details && event ${this.details && event
@@ -65,7 +80,21 @@ export class FrigateCardThumbnail extends LitElement {
</div> </div>
</div>` </div>`
: html``} : html``}
`; <ha-icon
class="timeline"
icon="mdi:target"
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
this.view
?.evolve({
view: 'timeline',
target: this.target,
childIndex: this.childIndex,
})
.dispatchChangeEvent(this);
}}
></ha-icon>`;
} }
/** /**
+317 -131
View File
@@ -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 { import {
CSSResultGroup, CSSResultGroup,
LitElement, LitElement,
@@ -7,16 +14,21 @@ import {
PropertyValues, PropertyValues,
} from 'lit'; } from 'lit';
import { DataSet } from 'vis-data/esnext'; import { DataSet } from 'vis-data/esnext';
import { HomeAssistant } from 'custom-card-helpers';
import { import {
DataGroupCollectionType, DataGroupCollectionType,
IdType,
Timeline, Timeline,
TimelineItem,
TimelineOptions, TimelineOptions,
TimelineOptionsCluster, TimelineOptionsCluster,
TimelineWindow,
} from 'vis-timeline/esnext'; } from 'vis-timeline/esnext';
import { HomeAssistant } from 'custom-card-helpers';
import { classMap } from 'lit/directives/class-map.js'; 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 { 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 { BrowseMediaUtil } from '../browse-media-util';
import { import {
@@ -27,10 +39,10 @@ import {
MEDIA_TYPE_VIDEO, MEDIA_TYPE_VIDEO,
MEDIA_CLASS_VIDEO, MEDIA_CLASS_VIDEO,
TimelineConfig, TimelineConfig,
FrigateEvent,
} from '../types'; } from '../types';
import { View } from '../view'; import { View, ViewContext } from '../view';
import { import {
contentsChanged,
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchFrigateCardEvent, dispatchFrigateCardEvent,
getCameraTitle, getCameraTitle,
@@ -45,18 +57,26 @@ interface FrigateCardGroupData {
id: string; id: string;
content: string; content: string;
} }
interface FrigateCardTimelineData { interface FrigateCardTimelineItem extends TimelineItem {
id: string;
content: string;
start: number;
end?: number;
source: FrigateBrowseMediaSource; source: FrigateBrowseMediaSource;
} }
class TimelineEventManager { interface TimelineViewContext extends ViewContext {
protected _dataset = new DataSet<FrigateCardTimelineData>(); window: TimelineWindow;
}
protected _contentCallback?: (FrigateBrowseMediaSource) => string; /**
* A manager to maintain/fetch timeline events.
*/
class TimelineEventManager {
protected _dataset = new DataSet<FrigateCardTimelineItem>();
// The earliest date managed.
protected _dateStart?: Date;
// The latest date managed.
protected _dateEnd?: Date;
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
constructor(params?: { constructor(params?: {
contentCallback?: (source: FrigateBrowseMediaSource) => string; contentCallback?: (source: FrigateBrowseMediaSource) => string;
@@ -64,20 +84,35 @@ class TimelineEventManager {
this._contentCallback = params?.contentCallback; this._contentCallback = params?.contentCallback;
} }
get dataset(): DataSet<FrigateCardTimelineData> { /**
* Retrieve the underlying dataset.
*/
get dataset(): DataSet<FrigateCardTimelineItem> {
return this._dataset; return this._dataset;
} }
/**
* Determine if the dataset is empty.
* @returns
*/
public isEmpty(): boolean { public isEmpty(): boolean {
return this._dataset.length === 0; return this._dataset.length === 0;
} }
/**
* Clear the dataset.
*/
public clear(): void { public clear(): void {
this._dataset.clear(); 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 { protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void {
const items: FrigateCardTimelineData[] = []; const items: FrigateCardTimelineItem[] = [];
target.children?.forEach((child) => { target.children?.forEach((child) => {
if (child.frigate) { if (child.frigate) {
const item = { const item = {
@@ -99,19 +134,68 @@ class TimelineEventManager {
this._dataset.update(items); 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<string, CameraConfig>,
start: Date,
end: Date,
): Promise<boolean> {
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, hass: HomeAssistant & ExtendedHomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
start: Date, start: Date,
end: Date, end: Date,
): Promise<void> { ): Promise<void> {
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<string, FrigateBrowseMediaSource>();
const fetchCameraEvents = async (camera: string): Promise<void> => { const fetchCameraEvents = async (camera: string): Promise<void> => {
const cameraConfig = cameras.get(camera); const cameraConfig = cameras.get(camera);
if (!cameraConfig) { if (!cameraConfig || !this._dateStart || !this._dateEnd) {
return; return;
} }
const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters( const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters(
@@ -127,11 +211,17 @@ class TimelineEventManager {
camera, camera,
await BrowseMediaUtil.browseMediaQuery(hass, { await BrowseMediaUtil.browseMediaQuery(hass, {
...browseMediaQueryParameters, ...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, unlimited: true,
}), }),
); );
} catch (e) { } 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 }) @property({ attribute: false })
protected cameras?: Map<string, CameraConfig>; protected cameras?: Map<string, CameraConfig>;
/** @property({ attribute: false })
* Set the timeline configuration. protected timelineConfig?: TimelineConfig;
*/
set timelineConfig(timelineConfig: TimelineConfig) {
this._timelineConfig = timelineConfig;
this._setOptions();
}
@state()
protected _timelineConfig?: TimelineConfig;
@state({ hasChanged: contentsChanged })
protected _timelineOptions?: TimelineOptions;
protected _timelineRef: Ref<HTMLElement> = createRef();
protected _timeline?: Timeline;
protected _events = new TimelineEventManager(); protected _events = new TimelineEventManager();
protected _refTimeline: Ref<HTMLElement> = createRef();
protected _thumbnails?: FrigateBrowseMediaSource;
protected _timeline?: Timeline;
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this._timelineConfig) { if (!this.hass || !this.view || !this.timelineConfig) {
return; return;
} }
const thumbnailsConfig = this._timelineConfig.controls.thumbnails; const thumbnailsConfig = this.timelineConfig.controls.thumbnails;
const timelineClasses = { const timelineClasses = {
timeline: true, timeline: true,
'left-margin': thumbnailsConfig.mode === 'left', 'left-margin': thumbnailsConfig.mode === 'left',
@@ -234,7 +313,7 @@ export class FrigateCardTimelineCore extends LitElement {
return html`<div return html`<div
class="${classMap(timelineClasses)}" class="${classMap(timelineClasses)}"
${ref(this._timelineRef)} ${ref(this._refTimeline)}
></div>`; ></div>`;
} }
@@ -251,74 +330,87 @@ export class FrigateCardTimelineCore extends LitElement {
console.info( console.info(
`Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`, `Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`,
); );
if (this.hass && this.cameras) { if (this.hass && this.cameras && this._timeline) {
// 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.
this._events this._events
.fetchEvents(this, this.hass, this.cameras, properties.start, properties.end) .fetchEventsIfNecessary(
.then(() => { this,
this._updateThumbnails(); 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. * Called when an object on the timeline is selected.
* @param _data The data about the selection. * @param data The data about the selection.
* @returns * @returns
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _timelineSelectHandler(_data: { items: string[]; event: Event }): void { protected _timelineSelectHandler(data: { items: string[]; event: Event }): void {
this._updateThumbnails(); 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'); 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) { if (!this._timeline) {
return; return;
} }
const selected = this._timeline?.getSelection(); const children: FrigateBrowseMediaSource[] = this._events.dataset
.get()
const timelineWindow = this._timeline.getWindow(); .filter((item) => BrowseMediaUtil.isTrueMedia(item.source))
const start = timelineWindow.start.getTime(); .map((item) => item.source);
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);
}
});
if (!children.length) { if (!children.length) {
return; return;
} }
const target = { const target = {
title: `Timeline ${start} - ${end}`, title: `Timeline events`,
media_class: MEDIA_CLASS_PLAYLIST, media_class: MEDIA_CLASS_PLAYLIST,
media_content_type: MEDIA_TYPE_VIDEO, media_content_type: MEDIA_TYPE_VIDEO,
media_content_id: '', media_content_id: '',
@@ -329,10 +421,16 @@ export class FrigateCardTimelineCore extends LitElement {
children: children, children: children,
}; };
dispatchFrigateCardEvent(this, 'thumbnails:set', { this._thumbnails = target;
target: target, const childIndex = this._findThumbnailIndex(this._timeline.getSelection());
childIndex: childIndex ?? undefined,
}); // Update the thumbnail carousel with the regenerated thumbnails.
this.view
?.evolve({
target: this._thumbnails,
childIndex: childIndex < 0 ? undefined : childIndex,
})
.dispatchChangeEvent(this);
} }
/** /**
@@ -350,19 +448,64 @@ export class FrigateCardTimelineCore extends LitElement {
return new DataSet(groups); 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. * Handle timeline resize.
*/ */
protected _setOptions(): void { protected _getOptions(): TimelineOptions | void {
if (!this._timelineConfig) { if (!this.timelineConfig) {
return; return;
} }
const [start, end] = this._getStartEnd();
// Configuration for the Timeline, see: // Configuration for the Timeline, see:
// https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options
this._timelineOptions = { return {
cluster: cluster: this._isClustering()
this._timelineConfig.clustering_threshold > 0
? { ? {
showStipes: true, showStipes: true,
// It would be better to automatically calculate `maxItems` from the // It would be better to automatically calculate `maxItems` from the
@@ -373,7 +516,20 @@ export class FrigateCardTimelineCore extends LitElement {
// and if we adjust `maxItems` then we can get into an infinite // and if we adjust `maxItems` then we can get into an infinite
// resize loop. Adjusting the `maxItems` of a timeline, after it's // resize loop. Adjusting the `maxItems` of a timeline, after it's
// created, also does not appear to work as expected. // created, also does not appear to work as expected.
maxItems: this._timelineConfig.clustering_threshold, 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 &&
(<FrigateCardTimelineItem>first).source.frigate?.event.label ===
(<FrigateCardTimelineItem>second).source.frigate?.event.label
);
},
} }
: (false as TimelineOptionsCluster), : (false as TimelineOptionsCluster),
minHeight: '100%', minHeight: '100%',
@@ -381,8 +537,8 @@ export class FrigateCardTimelineCore extends LitElement {
zoomMax: 31 * 24 * 60 * 60 * 1000, zoomMax: 31 * 24 * 60 * 60 * 1000,
zoomMin: 1 * 1000, zoomMin: 1 * 1000,
selectable: true, selectable: true,
start: this._getYesterday(), start: start,
end: this._getToday(), end: end,
groupHeightMode: 'fixed', groupHeightMode: 'fixed',
xss: { xss: {
disabled: false, 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. * Determine if the component should be updated.
* @param _changedProps The changed properties. * @param _changedProps The changed properties.
@@ -431,25 +569,63 @@ export class FrigateCardTimelineCore extends LitElement {
} }
/** /**
* Called on the first update. * Update the timeline from the view object.
* @param changedProps The changed properties.
*/ */
protected firstUpdated(changedProps: PropertyValues): void { protected async _updateTimelineFromView(): Promise<void> {
super.firstUpdated(changedProps); const event = this.view?.media?.frigate?.event;
const id = this.view?.media?.media_content_id;
if (changedProps.has('cameras')) { if (!this.hass || !this.cameras || !this.view || !event || !id || !this._timeline) {
this._events.clear(); return;
} }
if (this._events.isEmpty() && this.hass && this.cameras) { const [eventWindowStart, eventWindowEnd] = this._getStartEndFromEvent(event);
// Fetch an initial 1-day worth of events. await this._events.fetchEventsIfNecessary(
this._events.fetchEvents(
this, this,
this.hass, this.hass,
this.cameras, this.cameras,
this._getToday(), eventWindowStart,
this._getYesterday(), 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,23 +636,33 @@ export class FrigateCardTimelineCore extends LitElement {
protected updated(changedProperties: PropertyValues): void { protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties); super.updated(changedProperties);
if (this._timelineRef.value) { if (changedProperties.has('cameras')) {
if (this._timeline) { this._events.clear();
this._timeline.destroy(); this._timeline?.destroy();
this._timeline = undefined; this._timeline = undefined;
} }
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._timeline = new Timeline(
this._timelineRef.value, this._refTimeline.value,
this._events.dataset, this._events.dataset,
this._getGroups(), this._getGroups(),
this._timelineOptions, options,
); );
this._timeline.on('select', this._timelineSelectHandler.bind(this)); this._timeline.on('select', this._timelineSelectHandler.bind(this));
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
} }
} }
if (changedProperties.has('view')) {
this._updateTimelineFromView();
}
}
/** /**
* Return compiled CSS styles. * Return compiled CSS styles.
*/ */
+1 -7
View File
@@ -95,6 +95,7 @@ export class FrigateCardViewer extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.config=${this.viewerConfig.controls.thumbnails} .config=${this.viewerConfig.controls.thumbnails}
.browseMediaParams=${browseMediaQueryParameters}
> >
<frigate-card-viewer-carousel <frigate-card-viewer-carousel
.hass=${this.hass} .hass=${this.hass}
@@ -102,13 +103,6 @@ export class FrigateCardViewer extends LitElement {
.viewerConfig=${this.viewerConfig} .viewerConfig=${this.viewerConfig}
.browseMediaQueryParameters=${browseMediaQueryParameters} .browseMediaQueryParameters=${browseMediaQueryParameters}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelect>) => {
// 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,
});
}}
> >
</frigate-card-viewer-carousel> </frigate-card-viewer-carousel>
</frigate-card-surround-thumbnails>`; </frigate-card-surround-thumbnails>`;
+5 -2
View File
@@ -213,8 +213,11 @@
"event": { "event": {
"start": "Start", "start": "Start",
"duration": "Duration", "duration": "Duration",
"in_progress": "In Progress", "in_progress": "In Progress"
"retain_indefinitely": "Event will be indefinitely retained" },
"thumbnail": {
"retain_indefinitely": "Event will be indefinitely retained",
"timeline": "See event in timeline"
}, },
"error": { "error": {
"empty_response": "Received empty response from Home Assistant for request", "empty_response": "Received empty response from Home Assistant for request",
+7
View File
@@ -3,3 +3,10 @@ ha-icon.favorite {
color: var(--primary-color); color: var(--primary-color);
padding: 2px; padding: 2px;
} }
ha-icon.timeline {
position: absolute;
color: var(--primary-color);
padding: 2px;
right: 0px;
}
+2 -2
View File
@@ -335,8 +335,8 @@ export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
const menuSubmenuItemSchema = elementsBaseSchema.extend({ const menuSubmenuItemSchema = elementsBaseSchema.extend({
entity: z.string().optional(), entity: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
state_color: z.boolean().default(true), state_color: z.boolean().default(true).optional(),
selected: z.boolean().default(false), selected: z.boolean().default(false).optional(),
}); });
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>; export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
+11 -5
View File
@@ -1,12 +1,16 @@
import type { FrigateBrowseMediaSource, FrigateCardView } from './types.js'; import type { FrigateBrowseMediaSource, FrigateCardView } from './types.js';
import { dispatchFrigateCardEvent } from './common.js'; import { dispatchFrigateCardEvent } from './common.js';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ViewContext {}
export interface ViewEvolveParameters { export interface ViewEvolveParameters {
view?: FrigateCardView; view?: FrigateCardView;
camera?: string; camera?: string;
target?: FrigateBrowseMediaSource; target?: FrigateBrowseMediaSource;
childIndex?: number; childIndex?: number;
previous?: View; previous?: View;
context?: ViewContext;
} }
export interface ViewParameters extends ViewEvolveParameters { export interface ViewParameters extends ViewEvolveParameters {
@@ -20,6 +24,7 @@ export class View {
target?: FrigateBrowseMediaSource; target?: FrigateBrowseMediaSource;
childIndex?: number; childIndex?: number;
previous?: View; previous?: View;
context?: ViewContext;
constructor(params: ViewParameters) { constructor(params: ViewParameters) {
this.view = params?.view; this.view = params?.view;
@@ -27,6 +32,7 @@ export class View {
this.target = params?.target; this.target = params?.target;
this.childIndex = params?.childIndex; this.childIndex = params?.childIndex;
this.previous = params?.previous; this.previous = params?.previous;
this.context = params?.context;
} }
/** /**
@@ -38,7 +44,8 @@ export class View {
camera: this.camera, camera: this.camera,
target: this.target, target: this.target,
childIndex: this.childIndex, childIndex: this.childIndex,
previous: this.previous previous: this.previous,
context: this.context,
}); });
} }
@@ -54,7 +61,8 @@ export class View {
target: params.target ?? this.target, target: params.target ?? this.target,
childIndex: params.childIndex ?? this.childIndex, childIndex: params.childIndex ?? this.childIndex,
previous: params.previous ?? this.previous, previous: params.previous ?? this.previous,
}) context: params.context ?? this.context,
});
} }
/** /**
@@ -82,9 +90,7 @@ export class View {
* Determine if a view is for the media viewer. * Determine if a view is for the media viewer.
*/ */
public isViewerView(): boolean { public isViewerView(): boolean {
return ['clip', 'snapshot'].includes( return ['clip', 'snapshot'].includes(this.view);
this.view,
);
} }
/** /**