Maintain the selected range in the view.
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -62,7 +72,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
|||||||
/**
|
/**
|
||||||
* Handle gallery resize.
|
* Handle gallery resize.
|
||||||
*/
|
*/
|
||||||
protected _resizeHandler(): void {
|
protected _resizeHandler(): void {
|
||||||
if (this._carousel) {
|
if (this._carousel) {
|
||||||
this._carousel.reInit();
|
this._carousel.reInit();
|
||||||
}
|
}
|
||||||
@@ -71,7 +81,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
|||||||
/**
|
/**
|
||||||
* Component connected callback.
|
* Component connected callback.
|
||||||
*/
|
*/
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this._resizeObserver.observe(this);
|
this._resizeObserver.observe(this);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+341
-155
@@ -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) {
|
||||||
dispatchFrigateCardEvent(this, 'thumbnails:open');
|
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) {
|
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,39 +448,97 @@ 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
|
// rendered height of the timeline (or group within the timeline) so
|
||||||
// rendered height of the timeline (or group within the timeline) so
|
// as to not waste vertical space (e.g. after the user changes to
|
||||||
// as to not waste vertical space (e.g. after the user changes to
|
// fullscreen mode). Unfortunately this is not easy to do, as we
|
||||||
// fullscreen mode). Unfortunately this is not easy to do, as we
|
// don't know the height of the timeline until after it renders --
|
||||||
// don't know the height of the timeline until after it renders --
|
// 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 => {
|
||||||
: (false as TimelineOptionsCluster),
|
// 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),
|
||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
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,
|
eventWindowStart,
|
||||||
this._getToday(),
|
eventWindowEnd,
|
||||||
this._getYesterday(),
|
);
|
||||||
|
|
||||||
|
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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._timeline = new Timeline(
|
const options = this._getOptions();
|
||||||
this._timelineRef.value,
|
if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) {
|
||||||
this._events.dataset,
|
if (this._timeline) {
|
||||||
this._getGroups(),
|
// TODO this._timeline.setOptions(options);
|
||||||
this._timelineOptions,
|
} else {
|
||||||
);
|
this._timeline = new Timeline(
|
||||||
this._timeline.on('select', this._timelineSelectHandler.bind(this));
|
this._refTimeline.value,
|
||||||
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2,4 +2,11 @@ ha-icon.favorite {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
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
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -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,16 +90,14 @@ 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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a view is related to a clip or clips.
|
* Determine if a view is related to a clip or clips.
|
||||||
*/
|
*/
|
||||||
public isClipRelatedView(): boolean {
|
public isClipRelatedView(): boolean {
|
||||||
// TODO HACK HACK HACK
|
// TODO HACK HACK HACK
|
||||||
return ['clip', 'clips', 'timeline'].includes(this.view);
|
return ['clip', 'clips', 'timeline'].includes(this.view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user