Initial mini-timeline commit.

This commit is contained in:
Dermot Duffy
2022-09-22 17:44:43 -07:00
parent 1244cfc925
commit eb1b44eb27
19 changed files with 1461 additions and 819 deletions
+15 -3
View File
@@ -100,6 +100,7 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { View } from './view.js'; import { View } from './view.js';
import pkg from '../package.json'; import pkg from '../package.json';
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { TimelineDataManager } from './utils/timeline-data-manager.js';
/** A note on media callbacks: /** A note on media callbacks:
* *
@@ -201,6 +202,9 @@ export class FrigateCard extends LitElement {
// A cache of resolved media URLs/mimetypes for use in the whole card. // A cache of resolved media URLs/mimetypes for use in the whole card.
protected _resolvedMediaCache = new ResolvedMediaCache(); protected _resolvedMediaCache = new ResolvedMediaCache();
// Shared timeline data manager (for main timeline view and mini-timelines).
protected _timelineDataManager?: TimelineDataManager;
// The mouse handler may be called continually, throttle it to at most once // The mouse handler may be called continually, throttle it to at most once
// per second for performance reasons. // per second for performance reasons.
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000); protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
@@ -1014,7 +1018,7 @@ export class FrigateCard extends LitElement {
/** /**
* Called before each update. * Called before each update.
*/ */
protected willUpdate(): void { protected willUpdate(changedProps: PropertyValues): void {
// Side load the necessary elements if not already initialized. // Side load the necessary elements if not already initialized.
if (!this._initialized) { if (!this._initialized) {
sideLoadHomeAssistantElements().then((success) => { sideLoadHomeAssistantElements().then((success) => {
@@ -1023,6 +1027,12 @@ export class FrigateCard extends LitElement {
} }
}); });
} }
if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) {
this._timelineDataManager = new TimelineDataManager(
this._cameras, this._config.timeline.media, this._config.timeline.show_recordings
)
}
} }
/** /**
@@ -1877,7 +1887,7 @@ export class FrigateCard extends LitElement {
protected _render(): TemplateResult | void { protected _render(): TemplateResult | void {
const cameraConfig = this._getSelectedCameraConfig(); const cameraConfig = this._getSelectedCameraConfig();
if (!this._hass || !this._view || !cameraConfig) { if (!this._hass || !this._view || !cameraConfig || !this._cameras) {
return html``; return html``;
} }
@@ -1915,6 +1925,7 @@ export class FrigateCard extends LitElement {
.cameras=${this._cameras} .cameras=${this._cameras}
.viewerConfig=${this._getConfig().media_viewer} .viewerConfig=${this._getConfig().media_viewer}
.resolvedMediaCache=${this._resolvedMediaCache} .resolvedMediaCache=${this._resolvedMediaCache}
.timelineDataManager=${this._timelineDataManager}
> >
</frigate-card-viewer>` </frigate-card-viewer>`
: ``} : ``}
@@ -1922,9 +1933,9 @@ export class FrigateCard extends LitElement {
? html` <frigate-card-timeline ? html` <frigate-card-timeline
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.cameraConfig=${cameraConfig}
.cameras=${this._cameras} .cameras=${this._cameras}
.timelineConfig=${this._getConfig().timeline} .timelineConfig=${this._getConfig().timeline}
.timelineDataManager=${this._timelineDataManager}
> >
</frigate-card-timeline>` </frigate-card-timeline>`
: ``} : ``}
@@ -1945,6 +1956,7 @@ export class FrigateCard extends LitElement {
.conditionState=${this._conditionState} .conditionState=${this._conditionState}
.liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')} .liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')}
.cameras=${this._cameras} .cameras=${this._cameras}
.timelineDataManager=${this._timelineDataManager}
class="${classMap(liveClasses)}" class="${classMap(liveClasses)}"
> >
</frigate-card-live> </frigate-card-live>
+8 -1
View File
@@ -105,7 +105,14 @@ export class FrigateCardCarousel extends LitElement {
* @param index Slide number. * @param index Slide number.
*/ */
public carouselScrollTo(index: number): void { public carouselScrollTo(index: number): void {
this._carousel?.scrollTo(index, this.transitionEffect === 'none'); const scroll = () => this._carousel?.scrollTo(index, this.transitionEffect === 'none');
if (this._carousel) {
scroll();
} else {
this.updateComplete.then(() => {
scroll();
})
}
} }
/** /**
+10 -4
View File
@@ -68,6 +68,7 @@ import { renderTask } from '../utils/task.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import './image'; import './image';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { TimelineDataManager } from '../utils/timeline-data-manager.js';
// Number of seconds a signed URL is valid for. // Number of seconds a signed URL is valid for.
const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@@ -95,10 +96,13 @@ export class FrigateCardLive extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides; public liveOverrides?: LiveOverrides;
@property({ attribute: false })
public timelineDataManager?: TimelineDataManager;
// Whether or not the live view is currently in the background (i.e. preloaded // Whether or not the live view is currently in the background (i.e. preloaded
// but not visible) // but not visible)
@state() @state()
protected _inBackground?: boolean = true; protected _inBackground?: boolean = false;
// Intersection handler is used to detect when the live view flips between // Intersection handler is used to detect when the live view flips between
// foreground and background (in preload mode). // foreground and background (in preload mode).
@@ -122,7 +126,7 @@ export class FrigateCardLive extends LitElement {
* @param entries The IntersectionObserverEntry entries (should be only 1). * @param entries The IntersectionObserverEntry entries (should be only 1).
*/ */
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
this._inBackground = entries.every((entry) => !entry.isIntersecting); this._inBackground = !entries.some((entry) => entry.isIntersecting);
if ( if (
!this._inBackground && !this._inBackground &&
@@ -212,10 +216,12 @@ export class FrigateCardLive extends LitElement {
html`<frigate-card-surround-thumbnails html`<frigate-card-surround-thumbnails
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.config=${config.controls.thumbnails} .thumbnailConfig=${config.controls.thumbnails}
.timelineConfig=${config.controls.timeline}
.browseMediaParams=${browseMediaParams ?? undefined} .browseMediaParams=${browseMediaParams ?? undefined}
.cameras=${this.cameras} .cameras=${this.cameras}
?fetch=${!this._inBackground} .timelineDataManager=${this.timelineDataManager}
.inBackground=${this._inBackground}
@frigate-card:message=${(ev: CustomEvent<Message>) => { @frigate-card:message=${(ev: CustomEvent<Message>) => {
this._renderKey++; this._renderKey++;
this._messageReceivedPostRender = true; this._messageReceivedPostRender = true;
+61 -28
View File
@@ -1,3 +1,6 @@
import './surround.js';
import './timeline';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
@@ -7,6 +10,7 @@ import {
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import surroundThumbnailsStyle from '../scss/surround.scss'; import surroundThumbnailsStyle from '../scss/surround.scss';
import { import {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
@@ -14,7 +18,7 @@ import {
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
FrigateCardError, FrigateCardError,
FrigateCardView, MiniTimelineControlConfig,
ThumbnailsControlConfig, ThumbnailsControlConfig,
} from '../types.js'; } from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
@@ -22,9 +26,9 @@ import {
getFirstTrueMediaChildIndex, getFirstTrueMediaChildIndex,
multipleBrowseMediaQueryMerged, multipleBrowseMediaQueryMerged,
} from '../utils/ha/browse-media'; } from '../utils/ha/browse-media';
import { TimelineDataManager } from '../utils/timeline-data-manager';
import { View } from '../view.js'; import { View } from '../view.js';
import { dispatchFrigateCardErrorEvent } from './message.js'; import { dispatchFrigateCardErrorEvent } from './message.js';
import './surround.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
interface ThumbnailViewContext { interface ThumbnailViewContext {
@@ -47,13 +51,13 @@ export class FrigateCardSurround extends LitElement {
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public config?: ThumbnailsControlConfig; public thumbnailConfig?: ThumbnailsControlConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: MiniTimelineControlConfig;
@property({ attribute: false }) @property({ attribute: false })
public targetView?: FrigateCardView; public inBackground?: boolean;
@property({ attribute: true, type: Boolean })
public fetch?: boolean;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[]; public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
@@ -61,6 +65,9 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cameras?: Map<string, CameraConfig>; public cameras?: Map<string, CameraConfig>;
@property({ attribute: false })
public timelineDataManager?: TimelineDataManager;
/** /**
* Fetch thumbnail media when a target is not specified in the view (e.g. for * Fetch thumbnail media when a target is not specified in the view (e.g. for
* the live view). * the live view).
@@ -69,11 +76,11 @@ export class FrigateCardSurround extends LitElement {
*/ */
protected async _fetchMedia(): Promise<void> { protected async _fetchMedia(): Promise<void> {
if ( if (
!this.fetch || this.inBackground ||
!this.hass || !this.hass ||
!this.view || !this.view ||
!this.config || !this.thumbnailConfig ||
this.config.mode === 'none' || this.thumbnailConfig.mode === 'none' ||
this.view.target || this.view.target ||
!this.browseMediaParams || !this.browseMediaParams ||
!(this.view.context?.thumbnails?.fetch ?? true) !(this.view.context?.thumbnails?.fetch ?? true)
@@ -89,7 +96,6 @@ export class FrigateCardSurround extends LitElement {
if (getFirstTrueMediaChildIndex(parent) !== null) { if (getFirstTrueMediaChildIndex(parent) !== null) {
this.view this.view
?.evolve({ ?.evolve({
...(this.targetView && { view: this.targetView }),
target: parent, target: parent,
childIndex: null, childIndex: null,
@@ -105,7 +111,9 @@ export class FrigateCardSurround extends LitElement {
* @returns `true` if a drawer is used, `false` otherwise. * @returns `true` if a drawer is used, `false` otherwise.
*/ */
protected _hasDrawer(): boolean { protected _hasDrawer(): boolean {
return !!this.config && ['left', 'right'].includes(this.config.mode); return (
!!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode)
);
} }
/** /**
@@ -116,7 +124,7 @@ export class FrigateCardSurround extends LitElement {
// do so if properties relevant to the request have changed (as per their // do so if properties relevant to the request have changed (as per their
// hasChanged). // hasChanged).
if ( if (
['view', 'targetView', 'fetch', 'browseMediaParams'].some((prop) => ['view', 'fetch', 'browseMediaParams', 'inBackground'].some((prop) =>
changedProperties.has(prop), changedProperties.has(prop),
) )
) { ) {
@@ -129,7 +137,7 @@ export class FrigateCardSurround extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.config) { if (!this.hass || !this.view || !this.thumbnailConfig) {
return; return;
} }
@@ -139,9 +147,9 @@ export class FrigateCardSurround extends LitElement {
// (if the thumbnails are in a drawer). The new event needs to be dispatched // (if the thumbnails are in a drawer). The new event needs to be dispatched
// from the origin of the inbound event, so it can be handled by // from the origin of the inbound event, so it can be handled by
// <frigate-card-surround> . // <frigate-card-surround> .
if (this.config && this._hasDrawer()) { if (this.thumbnailConfig && this._hasDrawer()) {
dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
drawer: this.config.mode, drawer: this.thumbnailConfig.mode,
}); });
} }
}; };
@@ -150,31 +158,56 @@ export class FrigateCardSurround extends LitElement {
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')} @frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
> >
${this.config && this.config.mode !== 'none' ${this.thumbnailConfig &&
this.thumbnailConfig.mode !== 'none' &&
!this.inBackground
? html` <frigate-card-thumbnail-carousel ? html` <frigate-card-thumbnail-carousel
slot=${this.config.mode} slot=${this.thumbnailConfig.mode}
.hass=${this.hass} .hass=${this.hass}
.config=${this.config} .config=${this.thumbnailConfig}
.view=${this.view} .view=${this.view}
.target=${this.view.target} .target=${this.view.target}
.selected=${this.view.childIndex} .selected=${this.view.childIndex}
.cameras=${this.cameras} .cameras=${this.cameras}
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
@frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => { @frigate-card:thumbnail-carousel:tap=${(
ev: CustomEvent<ThumbnailCarouselTap>,
) => {
const child: FrigateBrowseMediaSource | null =
ev.detail.target?.children?.[ev.detail.childIndex] ?? null;
// Send the view change from the source of the tap event, so the // Send the view change from the source of the tap event, so the
// view change will be caught by the handler above (to close the drawer). // view change will be caught by the handler above (to close the drawer).
this.view if (child) {
?.evolve({ this.view
view: this.targetView || 'media', ?.evolve({
target: ev.detail.target, view: this.view.is('recording') ? 'recording' : 'media',
childIndex: ev.detail.childIndex, target: ev.detail.target,
context: null, childIndex: ev.detail.childIndex,
}) context: null,
.dispatchChangeEvent(ev.composedPath()[0]); ...(child?.frigate?.cameraID && {
camera: child?.frigate?.cameraID,
}),
})
.dispatchChangeEvent(ev.composedPath()[0]);
}
}} }}
> >
</frigate-card-thumbnail-carousel>` </frigate-card-thumbnail-carousel>`
: ''} : ''}
${this.timelineConfig && !this.inBackground
? html` <frigate-card-timeline-core
slot=${this.timelineConfig.mode}
.hass=${this.hass}
.view=${this.view}
.cameras=${this.cameras}
.mini=${true}
.timelineConfig=${this.timelineConfig}
.thumbnailDetails=${this.thumbnailConfig?.show_details}
.thumbnailSize=${this.thumbnailConfig?.size}
.timelineDataManager=${this.timelineDataManager}
>
</frigate-card-timeline-core>`
: ''}
<slot></slot> <slot></slot>
</frigate-card-surround>`; </frigate-card-surround>`;
} }
+1
View File
@@ -209,6 +209,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
.view=${this.view} .view=${this.view}
.target=${parent} .target=${parent}
.childIndex=${childIndex} .childIndex=${childIndex}
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)}
.clientID=${cameraConfig?.frigate.client_id} .clientID=${cameraConfig?.frigate.client_id}
?details=${this.config?.show_details} ?details=${this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control} ?show_favorite_control=${this.config?.show_favorite_control}
+28 -11
View File
@@ -2,17 +2,12 @@ import { format, fromUnixTime } from 'date-fns';
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailStyle from '../scss/thumbnail.scss'; import thumbnailStyle from '../scss/thumbnail.scss';
import type {
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateEvent,
FrigateRecording,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js';
import { retainEvent } from '../utils/frigate.js'; import { retainEvent } from '../utils/frigate.js';
@@ -20,7 +15,14 @@ import { getEventDurationString } from '../utils/ha/browse-media.js';
import { renderTask } from '../utils/task.js'; import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { MediaSeek } from './viewer.js';
import type {
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateEvent,
FrigateRecording,
} from '../types.js';
// The minimum width of a thumbnail with details enabled. // The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@@ -45,9 +47,7 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
this, this,
this._embedThumbnailTask, this._embedThumbnailTask,
(embeddedThumbnail: string | null) => (embeddedThumbnail: string | null) =>
embeddedThumbnail embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
? html`<img src="${embeddedThumbnail}" />`
: html``
) )
: html`<ha-icon : html`<ha-icon
icon="mdi:image-off" icon="mdi:image-off"
@@ -86,6 +86,9 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public event?: FrigateEvent; public event?: FrigateEvent;
@property({ attribute: false })
public mediaSeek?: MediaSeek;
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.event) { if (!this.event) {
return; return;
@@ -101,6 +104,12 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
<span class="heading">${localize('event.duration')}:</span> <span class="heading">${localize('event.duration')}:</span>
<span>${getEventDurationString(this.event)}</span> <span>${getEventDurationString(this.event)}</span>
</div> </div>
${this.mediaSeek
? html` <div>
<span class="heading">${localize('event.seek')}</span>
<span>${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}</span>
</div>`
: html``}
</div> </div>
<div class="right"> <div class="right">
<span class="larger">${score}</span> <span class="larger">${score}</span>
@@ -117,16 +126,19 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public recording?: FrigateRecording; public recording?: FrigateRecording;
@property({ attribute: false })
public mediaSeek?: MediaSeek;
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.recording) { if (!this.recording) {
return; return;
} }
return html`<div class="left"> return html`<div class="left">
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div> <div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
${this.recording.seek_time ${this.mediaSeek
? html` <div> ? html` <div>
<span class="heading">${localize('recording.seek')}</span> <span class="heading">${localize('recording.seek')}</span>
<span>${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')}</span> <span>${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}</span>
</div>` </div>`
: html``} : html``}
</div> </div>
@@ -161,6 +173,9 @@ export class FrigateCardThumbnail extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public childIndex?: number; public childIndex?: number;
@property({ attribute: false })
public mediaSeek?: MediaSeek;
// =================================================== // ===================================================
// Raw interface (can override target-based interface) // Raw interface (can override target-based interface)
// =================================================== // ===================================================
@@ -263,10 +278,12 @@ export class FrigateCardThumbnail extends LitElement {
${this.details && event ${this.details && event
? html`<frigate-card-thumbnail-details-event ? html`<frigate-card-thumbnail-details-event
.event=${event ?? undefined} .event=${event ?? undefined}
.mediaSeek=${this.mediaSeek}
></frigate-card-thumbnail-details-event>` ></frigate-card-thumbnail-details-event>`
: this.details && recording : this.details && recording
? html`<frigate-card-thumbnail-details-recording ? html`<frigate-card-thumbnail-details-recording
.recording=${recording ?? undefined} .recording=${recording ?? undefined}
.mediaSeek=${this.mediaSeek}
></frigate-card-thumbnail-details-recording>` ></frigate-card-thumbnail-details-recording>`
: html``} : html``}
${this.show_timeline_control ${this.show_timeline_control
+615 -644
View File
File diff suppressed because it is too large Load Diff
+40 -13
View File
@@ -55,6 +55,26 @@ import './surround-thumbnails';
import { EmblaCarouselPlugins } from './carousel.js'; import { EmblaCarouselPlugins } from './carousel.js';
import { renderTask } from '../utils/task.js'; import { renderTask } from '../utils/task.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { TimelineDataManager } from '../utils/timeline-data-manager.js';
export interface MediaSeek {
// Specifies the point at which this recording should be played, the
// seek_time is the date of the desired play point (for display purposes
// usually), and seek_seconds is the number of seconds to seek into the video
// stream to reach that point.
seekTime: number;
seekSeconds: number;
}
export interface MediaViewerViewContext {
seek: Map<number, MediaSeek>;
}
declare module 'view' {
interface ViewContext {
mediaViewer?: MediaViewerViewContext;
}
}
@customElement('frigate-card-viewer') @customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement { export class FrigateCardViewer extends LitElement {
@@ -73,6 +93,9 @@ export class FrigateCardViewer extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache; public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
public timelineDataManager?: TimelineDataManager;
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
@@ -114,7 +137,9 @@ export class FrigateCardViewer extends LitElement {
return html` <frigate-card-surround-thumbnails return html` <frigate-card-surround-thumbnails
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.config=${this.viewerConfig.controls.thumbnails} .thumbnailConfig=${this.viewerConfig.controls.thumbnails}
.timelineConfig=${this.viewerConfig.controls.timeline}
.timelineDataManager=${this.timelineDataManager}
.cameras=${this.cameras} .cameras=${this.cameras}
> >
<frigate-card-viewer-carousel <frigate-card-viewer-carousel
@@ -202,7 +227,7 @@ export class FrigateCardViewerCarousel extends LitElement {
if (oldView) { if (oldView) {
if ( if (
oldView.target === this.view?.target && oldView.target === this.view?.target &&
this.view.childIndex != oldView.childIndex oldView.childIndex !== this.view.childIndex
) { ) {
const slide = this._getSlideForChild(this.view.childIndex); const slide = this._getSlideForChild(this.view.childIndex);
if ( if (
@@ -215,8 +240,14 @@ export class FrigateCardViewerCarousel extends LitElement {
} }
} }
} }
}
// Seek into the video if the seek time has changed (this is also called
// on media load, since the media may or may not have been loaded at
// this point).
if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) {
this._recordingSeekHandler();
}
}
super.updated(changedProperties); super.updated(changedProperties);
} }
@@ -663,15 +694,12 @@ export class FrigateCardViewerCarousel extends LitElement {
* Fire a media show event when a slide is selected. * Fire a media show event when a slide is selected.
*/ */
protected _recordingSeekHandler(): void { protected _recordingSeekHandler(): void {
// If this is a recording and play is desired to be started from a const player = this._getPlayer();
// particular point, seek to that point. Use the media off the slide itself const childIndex = this.view?.childIndex ?? null;
// -- when the slide is changed, the media show event may be dispatched const seek =
// before this.view has been updated to reflect the new selection. childIndex !== null ? this.view?.context?.mediaViewer?.seek.get(childIndex) : null;
const player = this._getPlayer() as FrigateCardMediaPlayer & { if (player && seek) {
media?: FrigateBrowseMediaSource; player.seek(seek.seekSeconds);
};
if (player && player.media && player.media.frigate?.recording?.seek_seconds) {
player.seek(player.media.frigate.recording.seek_seconds);
} }
} }
@@ -718,7 +746,6 @@ export class FrigateCardViewerCarousel extends LitElement {
url=${ifDefined( url=${ifDefined(
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url), lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url),
)} )}
.media=${mediaToRender}
.hass=${this.hass} .hass=${this.hass}
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => { @frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(slideIndex, e); wrapMediaLoadedEventForCarousel(slideIndex, e);
+6 -1
View File
@@ -362,7 +362,8 @@
"duration": "Duration", "duration": "Duration",
"in_progress": "In Progress", "in_progress": "In Progress",
"score": "Score", "score": "Score",
"start": "Start" "start": "Start",
"seek": "Seek"
}, },
"recording": { "recording": {
"events": "Events", "events": "Events",
@@ -373,6 +374,10 @@
"retain_indefinitely": "Event will be indefinitely retained", "retain_indefinitely": "Event will be indefinitely retained",
"timeline": "See event in timeline" "timeline": "See event in timeline"
}, },
"timeline": {
"lock": "Lock timeline to a single event",
"unlock": "Unlock timeline"
},
"elements": { "elements": {
"ptz": { "ptz": {
"up": "Up", "up": "Up",
+18 -3
View File
@@ -25,6 +25,8 @@ customElements.whenDefined('ha-hls-player').then(() => {
@query('#video') @query('#video')
protected _video: HTMLVideoElement; protected _video: HTMLVideoElement;
protected _controlsVisibilityTimerID: number | null = null;
/** /**
* Play the video. * Play the video.
*/ */
@@ -65,7 +67,20 @@ customElements.whenDefined('ha-hls-player').then(() => {
*/ */
public seek(seconds: number): void { public seek(seconds: number): void {
if (this._video) { if (this._video) {
// Hide the controls while programatically seeking, and make them
// visible again a short time after the last seek (controls are annoying
// during timeline seeking)
this._video.controls = false;
this._video.currentTime = seconds; this._video.currentTime = seconds;
if (this._controlsVisibilityTimerID !== null) {
window.clearTimeout(this._controlsVisibilityTimerID);
}
this._controlsVisibilityTimerID = window.setTimeout(() => {
this._video.controls = true;
this._controlsVisibilityTimerID = null;
}, 1000);
} }
} }
@@ -112,7 +127,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
}); });
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-ha-hls-player": FrigateCardHaHlsPlayer 'frigate-card-ha-hls-player': FrigateCardHaHlsPlayer;
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ div.control-surround {
ha-icon.control { ha-icon.control {
color: var(--secondary-color, white); color: var(--secondary-color, white);
background-color: rgba(0, 0, 0, 0.7); background-color: rgba(0, 0, 0, 0.7);
opacity: 0.7; opacity: 0.5;
pointer-events: all; pointer-events: all;
--mdc-icon-size: #{$drawer-icon-size}; --mdc-icon-size: #{$drawer-icon-size};
+3 -1
View File
@@ -13,6 +13,8 @@ div.left {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
font-size: 0.8rem;
line-height: normal;
} }
div.right { div.right {
align-items: center; align-items: center;
@@ -42,5 +44,5 @@ span.heading {
div.larger, div.larger,
span.larger { span.larger {
font-size: 1.5rem; font-size: 1.4rem;
} }
+26 -12
View File
@@ -4,10 +4,6 @@
:host { :host {
width: 100%; width: 100%;
height: 100%;
background-color: var(--card-background-color);
padding-bottom: 5px;
// Share the screen space with thumbnails that may be above/below. // Share the screen space with thumbnails that may be above/below.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -27,14 +23,6 @@ frigate-card-thumbnail[details] {
div.timeline { div.timeline {
flex: 1; flex: 1;
} }
div.timeline.left-margin {
// Clearance for the drawer button.
margin-left: calc(drawer.$drawer-icon-size + 1px);
}
div.timeline.right-margin {
// Clearance for the drawer button.
margin-right: calc(drawer.$drawer-icon-size + 1px);
}
.vis-text { .vis-text {
color: var(--primary-text-color) !important; color: var(--primary-text-color) !important;
@@ -68,6 +56,14 @@ div.timeline.right-margin {
opacity: 0.1; opacity: 0.1;
} }
// If there are no timeline groups shown (e.g. mini mode with a single camera),
// ensure the background (recordings) always span the full height. Otherwise, in
// cases where there are no events, the background is incorrectly rendered too
// short by visjs.
:host:not([groups]) .vis-item.vis-background {
min-height: 100%;
}
.vis-item:not(.vis-background) { .vis-item:not(.vis-background) {
cursor: pointer; cursor: pointer;
} }
@@ -127,3 +123,21 @@ div.vis-tooltip {
// Use browser default font-family for tooltips. // Use browser default font-family for tooltips.
font-family: unset; font-family: unset;
} }
.target_bar {
border-left: 2px solid var(--primary-color);
opacity: 0.7;
box-shadow: 0px 0px 3px 1px var(--primary-color);
// Prevent the mouse interacting with the custom time.
pointer-events: none;
}
ha-icon.lock {
position: absolute;
right: 2px;
bottom: 2px;
color: var(--primary-color);
z-index: 10;
cursor: pointer;
}
+47 -30
View File
@@ -38,6 +38,7 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
const FRIGATE_CARD_VIEWS = [ const FRIGATE_CARD_VIEWS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED, ...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
'recording',
// Media: A generic piece of media (could be clip, snapshot, recording). // Media: A generic piece of media (could be clip, snapshot, recording).
'media', 'media',
@@ -655,6 +656,45 @@ const thumbnailsControlSchema = z.object({
}); });
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>; export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
/**
* Core/Mini timeline controls configuration section.
*/
const timelineCoreConfigDefault = {
clustering_threshold: 3,
media: 'all' as const,
window_seconds: 60 * 60,
show_recordings: true,
};
const timelineCoreConfigSchema = z
.object({
clustering_threshold: z
.number()
.optional()
.default(timelineCoreConfigDefault.clustering_threshold),
media: z
.enum(['all', 'clips', 'snapshots'])
.optional()
.default(timelineCoreConfigDefault.media),
window_seconds: z
.number()
.min(1 * 60)
.max(24 * 60 * 60)
.optional()
.default(timelineCoreConfigDefault.window_seconds),
show_recordings: z
.boolean()
.optional()
.default(timelineCoreConfigDefault.show_recordings),
});
export type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>;
const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
mode: z.enum(['none', 'above', 'below']),
});
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
/** /**
* Next/Previous Control configuration section. * Next/Previous Control configuration section.
*/ */
@@ -787,6 +827,7 @@ const liveOverridableConfigSchema = z
.default(liveConfigDefault.controls.thumbnails.media), .default(liveConfigDefault.controls.thumbnails.media),
}) })
.default(liveConfigDefault.controls.thumbnails), .default(liveConfigDefault.controls.thumbnails),
timeline: miniTimelineConfigSchema.optional(),
title: titleControlConfigSchema title: titleControlConfigSchema
.extend({ .extend({
mode: titleControlConfigSchema.shape.mode.default( mode: titleControlConfigSchema.shape.mode.default(
@@ -989,6 +1030,7 @@ const viewerConfigSchema = z
), ),
}) })
.default(viewerConfigDefault.controls.thumbnails), .default(viewerConfigDefault.controls.thumbnails),
timeline: miniTimelineConfigSchema.optional(),
title: titleControlConfigSchema title: titleControlConfigSchema
.extend({ .extend({
mode: titleControlConfigSchema.shape.mode.default( mode: titleControlConfigSchema.shape.mode.default(
@@ -1082,10 +1124,7 @@ const dimensionsConfigSchema = z
* Timeline configuration section. * Timeline configuration section.
*/ */
const timelineConfigDefault = { const timelineConfigDefault = {
clustering_threshold: 3, ...timelineCoreConfigDefault,
media: 'all' as const,
window_seconds: 60 * 60,
show_recordings: true,
controls: { controls: {
thumbnails: { thumbnails: {
mode: 'left' as const, mode: 'left' as const,
@@ -1096,26 +1135,8 @@ const timelineConfigDefault = {
}, },
}, },
}; };
const timelineConfigSchema = z
.object({ const timelineConfigSchema = timelineCoreConfigSchema.extend({
clustering_threshold: z
.number()
.optional()
.default(timelineConfigDefault.clustering_threshold),
media: z
.enum(['all', 'clips', 'snapshots'])
.optional()
.default(timelineConfigDefault.media),
window_seconds: z
.number()
.min(1 * 60)
.max(24 * 60 * 60)
.optional()
.default(timelineConfigDefault.window_seconds),
show_recordings: z
.boolean()
.optional()
.default(timelineConfigDefault.show_recordings),
controls: z controls: z
.object({ .object({
thumbnails: thumbnailsControlSchema thumbnails: thumbnailsControlSchema
@@ -1362,16 +1383,11 @@ export interface FrigateEvent {
} }
export interface FrigateRecording { export interface FrigateRecording {
// Frigate camera name (may not be unique)
camera: string; camera: string;
start_time: number; start_time: number;
end_time: number; end_time: number;
events: number; events: number;
// Specifies the point at which this recording should be played, the
// seek_time is the date of the desired play point, and seek_seconds is the
// number of seconds to seek to reach that point.
seek_time?: number;
seek_seconds?: number;
} }
export interface FrigateBrowseMediaSource extends BrowseMediaSource { export interface FrigateBrowseMediaSource extends BrowseMediaSource {
@@ -1379,6 +1395,7 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource {
frigate?: { frigate?: {
event?: FrigateEvent; event?: FrigateEvent;
recording?: FrigateRecording; recording?: FrigateRecording;
cameraID?: string;
}; };
} }
+35
View File
@@ -72,3 +72,38 @@ export function getCameraIcon(
): string { ): string {
return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video'; return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video';
} }
/**
* Get all cameras that depend on a given camera.
* @param cameras Cameras map.
* @param camera Name of the target camera.
* @returns A set of query parameters.
*/
export const getAllDependentCameras = (
cameras: Map<string, CameraConfig>,
camera?: string,
): Set<string> => {
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (camera: string): void => {
const cameraConfig = cameras.get(camera);
if (cameraConfig) {
cameraIDs.add(camera);
const dependentCameras: Set<string> = new Set();
(cameraConfig.dependencies.cameras || []).forEach((item) =>
dependentCameras.add(item),
);
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
for (const eventCameraID of dependentCameras) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
if (camera) {
getDependentCameras(camera);
}
return cameraIDs;
};
+3 -27
View File
@@ -74,9 +74,9 @@ export const getRecordingsSummary = async (
hass, hass,
recordingSummarySchema, recordingSummarySchema,
{ {
type: "frigate/recordings/summary", type: 'frigate/recordings/summary',
instance_id: client_id, instance_id: client_id,
camera: camera_name camera: camera_name,
}, },
true, true,
); );
@@ -102,7 +102,7 @@ export const getRecordingSegments = async (
hass, hass,
recordingSegmentsSchema, recordingSegmentsSchema,
{ {
type: "frigate/recordings/get", type: 'frigate/recordings/get',
instance_id: client_id, instance_id: client_id,
camera: camera_name, camera: camera_name,
before: Math.floor(before.getTime() / 1000), before: Math.floor(before.getTime() / 1000),
@@ -144,27 +144,3 @@ export async function retainEvent(
}); });
} }
} }
/**
* Get an id that unique identifies a particular camera (not zone, object, etc)
* within a particular Frigate instance. ID will not (necessarily) be unique
* within the card.
* @param cameraConfig The camera config.
*/
export const getUniqueFrigateCameraID = (config: CameraConfig): string => {
return [config.frigate.client_id, config.frigate.camera_name].join('/');
};
/**
* Get an id that unique identifies a source of Frigate events. ID will not
* (necessarily) be unique within the card.
* @param cameraConfig The camera config.
*/
export const getUniqueFrigateCameraEventsID = (config: CameraConfig): string => {
return [
config.frigate.client_id,
config.frigate.camera_name,
config.frigate.label,
config.frigate.zone,
].join('/');
};
+27 -33
View File
@@ -27,7 +27,7 @@ import {
MEDIA_TYPE_VIDEO, MEDIA_TYPE_VIDEO,
} from '../../types.js'; } from '../../types.js';
import { View } from '../../view.js'; import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js'; import { getAllDependentCameras, getCameraTitle } from '../camera.js';
/** /**
* Return the Frigate event_id given a FrigateBrowseMediaSource object. * Return the Frigate event_id given a FrigateBrowseMediaSource object.
@@ -78,7 +78,7 @@ export const getFirstTrueMediaChildIndex = (
* @param media_content_id The media content id to browse. * @param media_content_id The media content id to browse.
* @returns A FrigateBrowseMediaSource object or null on malformed. * @returns A FrigateBrowseMediaSource object or null on malformed.
*/ */
export const browseMedia = async ( const browseMedia = async (
hass: HomeAssistant, hass: HomeAssistant,
media_content_id: string, media_content_id: string,
): Promise<FrigateBrowseMediaSource> => { ): Promise<FrigateBrowseMediaSource> => {
@@ -95,11 +95,11 @@ export const browseMedia = async (
* @param params The search parameters to use to search for media. * @param params The search parameters to use to search for media.
* @returns A FrigateBrowseMediaSource object or null on malformed. * @returns A FrigateBrowseMediaSource object or null on malformed.
*/ */
export const browseMediaQuery = async ( const browseMediaQuery = async (
hass: HomeAssistant, hass: HomeAssistant,
params: BrowseMediaQueryParameters, params: BrowseMediaQueryParameters,
): Promise<FrigateBrowseMediaSource> => { ): Promise<FrigateBrowseMediaSource> => {
return browseMedia( const result = await browseMedia(
hass, hass,
// Defined in: // Defined in:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
@@ -118,6 +118,14 @@ export const browseMediaQuery = async (
params.zone, params.zone,
].join('/'), ].join('/'),
); );
// If a cameraID was specified, imprint each child with that id for
// traceability.
if (params.cameraID) {
result.children?.forEach((child: FrigateBrowseMediaSource) => {
(child.frigate ??= {}).cameraID = params.cameraID;
})
}
return result;
}; };
/** /**
@@ -259,27 +267,7 @@ export const getFullDependentBrowseMediaQueryParameters = (
camera: string, camera: string,
mediaType?: 'clips' | 'snapshots', mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null => { ): BrowseMediaQueryParameters[] | null => {
const cameraIDs: Set<string> = new Set(); const cameraIDs = getAllDependentCameras(cameras, camera);
const getDependentCameras = (camera: string): void => {
const cameraConfig = cameras.get(camera);
if (cameraConfig) {
cameraIDs.add(camera);
const dependentCameras: Set<string> = new Set();
(cameraConfig.dependencies.cameras || []).forEach((item) =>
dependentCameras.add(item),
);
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
for (const eventCameraID of dependentCameras) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
getDependentCameras(camera);
const params: BrowseMediaQueryParameters[] = []; const params: BrowseMediaQueryParameters[] = [];
for (const cameraID of cameraIDs) { for (const cameraID of cameraIDs) {
const param = getBrowseMediaQueryParameters( const param = getBrowseMediaQueryParameters(
@@ -435,9 +423,10 @@ export const createVideoChild = (
options?: { options?: {
thumbnail?: string; thumbnail?: string;
recording?: FrigateRecording; recording?: FrigateRecording;
cameraID?: string,
}, },
): FrigateBrowseMediaSource => { ): FrigateBrowseMediaSource => {
return { const result: FrigateBrowseMediaSource = {
title: title, title: title,
media_class: MEDIA_CLASS_VIDEO, media_class: MEDIA_CLASS_VIDEO,
media_content_type: MEDIA_TYPE_VIDEO, media_content_type: MEDIA_TYPE_VIDEO,
@@ -445,13 +434,18 @@ export const createVideoChild = (
can_play: true, can_play: true,
can_expand: false, can_expand: false,
thumbnail: options?.thumbnail ?? null, thumbnail: options?.thumbnail ?? null,
children: null, children: null
...(options?.recording && { }
frigate: { if (options?.recording || options?.cameraID) {
recording: options.recording, result.frigate = {}
}, if (options?.recording) {
}), result.frigate.recording = options.recording;
}; }
if (options?.cameraID) {
result.frigate.cameraID = options.cameraID;
}
}
return result;
}; };
/** /**
+510
View File
@@ -0,0 +1,510 @@
import { HomeAssistant } from 'custom-card-helpers';
import { DataSet, DataView } from 'vis-data/esnext';
import { IdType, TimelineItem } from 'vis-timeline/esnext';
import { CAMERA_BIRDSEYE } from '../const.js';
import {
BrowseMediaQueryParameters,
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateCardError,
FrigateEvent,
} from '../types.js';
import { errorToConsole } from '../utils/basic.js';
import {
getRecordingSegments,
getRecordingsSummary,
RecordingSegments,
RecordingSummary,
} from './frigate.js';
import {
getBrowseMediaQueryParameters,
isTrueMedia,
multipleBrowseMediaQuery,
} from './ha/browse-media.js';
import { dispatchFrigateCardErrorEvent } from '../components/message.js';
const RECORDING_SEGMENT_TOLERANCE = 60;
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
export interface FrigateCardTimelineItem extends TimelineItem {
// DataView has issues using datasets with Date objects, so avoid them and use
// numbers instead.
start: number;
end?: number;
event?: FrigateEvent;
source?: FrigateBrowseMediaSource;
}
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
export interface RecordingSegmentsItem {
id: string;
cameraID: string;
start: number;
end: number;
}
/**
* Sort the timeline items most recent to least recent.
* @param a The first item.
* @param b The second item.
* @returns -1, 0, 1 (standard array sort function configuration).
*/
export const sortTimelineItemsYoungestToOldest = (
a: FrigateCardTimelineItem,
b: FrigateCardTimelineItem,
): number => {
if (a.start < b.start) {
return 1;
}
if (a.start > b.start) {
return -1;
}
return 0;
};
/**
* Sort the segments least recent to most recent.
* @param a The first item.
* @param b The second item.
* @returns -1, 0, 1 (standard array sort function configuration).
*/
export const sortSegmentsOldestToYoungest = (
a: RecordingSegmentsItem,
b: RecordingSegmentsItem,
): number => {
if (a.start < b.start) {
return -1;
}
if (a.start > b.start) {
return 1;
}
return 0;
};
/**
* A manager to maintain/fetch timeline events.
*/
export class TimelineDataManager {
protected _recordingSummary: Map<string, RecordingSummary | null> = new Map();
protected _recordingSegments = new DataSet<RecordingSegmentsItem>();
protected _dataset = new DataSet<FrigateCardTimelineItem>();
// The earliest date managed.
protected _dateStart: Date | null = null;
// The latest date managed.
protected _dateEnd: Date | null = null;
// The last fetch date.
protected _dateFetch: Date | null = null;
// The maximum allowable age of fetch data (will not fetch more frequently
// than this).
protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
protected _cameras: Map<string, CameraConfig>;
protected _mediaType: TimelineMediaType;
protected _recordings: boolean;
constructor(
cameras: Map<string, CameraConfig>,
mediaType: TimelineMediaType,
recordings: boolean,
) {
this._cameras = cameras;
this._mediaType = mediaType;
this._recordings = recordings;
}
// Get the last event fetch date.
get lastFetchDate(): Date | null {
return this._dateFetch ?? null;
}
public getRecordingSummaryForCamera(cameraID: string): RecordingSummary | null {
return this._recordingSummary.get(cameraID) ?? null;
}
public createDataView(cameraIDs: Set<string>): DataView<FrigateCardTimelineItem> {
return new DataView(this._dataset, {
filter: (item: FrigateCardTimelineItem) =>
!!item.group && cameraIDs.has(String(item.group)),
});
}
public createSegmentDataView(): DataView<RecordingSegmentsItem> {
return new DataView(this._recordingSegments);
}
get recordingSegments(): DataSet<RecordingSegmentsItem> {
return this._recordingSegments;
}
/**
* Rewrite an item as-is. May be useful in cases where clustering may need to
* be recalculated.
* @param id The id to rewrite.
*/
public rewriteItem(id: IdType): void {
// 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._dataset.get(id);
if (item) {
this._dataset.updateOnly(item);
}
}
/**
* Add a FrigateBrowseMediaSource object to the managed timeline.
* @param cameraID The id the camera this object is from.
* @param target The FrigateBrowseMediaSource to add.
*/
protected _addMediaSource(target: FrigateBrowseMediaSource): void {
const items: FrigateCardTimelineItem[] = [];
target.children?.forEach((child) => {
const event = child.frigate?.event;
const cameraID = child.frigate?.cameraID;
if (
cameraID &&
event &&
isTrueMedia(child) &&
['video', 'image'].includes(child.media_content_type)
) {
let item = this._dataset.get(event.id);
//const st = fromUnixTime();
if (!item) {
item = {
id: event.id,
group: cameraID,
content: '',
start: event.start_time * 1000,
event: event,
};
}
if (
(child.media_content_type === 'video' &&
['all', 'clips'].includes(this._mediaType)) ||
(!item.source &&
child.media_content_type === 'image' &&
['all', 'snapshots'].includes(this._mediaType))
) {
item.source = child;
}
if (event.end_time) {
item['end'] = event.end_time * 1000;
item['type'] = 'range';
} else {
item['type'] = 'point';
}
items.push(item);
}
});
this._dataset.update(items);
}
/**
* 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(now: Date, start: Date, end?: Date): boolean {
// Never fetched: no coverage.
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
return false;
}
// If the most recent fetch is older than maxAgeSeconds: no coverage.
if (
this._maxAgeSeconds &&
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
) {
return false;
}
// If the most requested data is earlier than the earliest stored: no
// coverage.
if (start < this._dateStart) {
return false;
}
// If there's no end time specified: there IS coverage.
if (!end) {
return true;
}
// If the requested end time is older than the oldest requested: there IS
// coverage.
if (end.getTime() < this._dateEnd.getTime()) {
return true;
}
// If there's no maxAgeSeconds specified: no coverage.
if (!this._maxAgeSeconds) {
return false;
}
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
return false;
}
// End time is within `_maxAgeSeconds` of the latest data: there IS
// coverage.
return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime();
}
/**
* Fetch events if no coverage in given range.
* @param element The element to send error events from.
* @param hass The HomeAssistant object.
* @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 fetchIfNecessary(
element: HTMLElement,
hass: ExtendedHomeAssistant,
start: Date,
end: Date,
): Promise<boolean> {
// Cannot fetch the future, always clip the end date to now so as to avoid
// checking for coverage that could not possibly exist yet.
const now = new Date();
end = end > now ? now : end;
if (this.hasCoverage(now, start, end)) {
return false;
}
const oldStart = this._dateStart;
const oldEnd = this._dateEnd;
let segmentStart: Date | null = null;
let segmentEnd: Date | null = null;
if (!this._dateStart || start < this._dateStart) {
this._dateStart = start;
segmentStart = start;
} else {
segmentStart = oldEnd ?? end;
}
if (!this._dateEnd || end > this._dateEnd) {
this._dateEnd = end;
segmentEnd = end;
} else {
segmentEnd = oldStart ?? start;
}
this._dateFetch = new Date();
await Promise.all([
// Events are always fetched for the maximum extent of the managed
// range. This is because events may change at any point in time
// (e.g. a long-running event that ends).
this._fetchEvents(element, hass, this._dateStart, this._dateEnd),
...(this._recordings ? [this._fetchRecordingSummary(hass)] : []),
...(this._recordings && segmentEnd > segmentStart
? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)]
: []),
]);
return true;
}
/**
* Fetch recording segments for cameras.
* @param hass The HomeAssistant object.
* @param start Fetch segments that start later than this date.
* @param end Fetch segments that start earlier than this date.
*/
protected async _fetchRecordingSegments(
hass: ExtendedHomeAssistant,
start: Date,
end: Date,
): Promise<void> {
const results: Map<string, RecordingSegments> = new Map();
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
if (!config || !config.frigate.camera_name || !hass) {
return;
}
try {
const cameraResults = await getRecordingSegments(
hass,
config.frigate.client_id,
config.frigate.camera_name,
end,
start,
);
results.set(camera, cameraResults);
} catch (e) {
errorToConsole(e as Error);
}
};
await Promise.all(
Array.from(this._cameras.keys()).map((camera) =>
fetch(camera, this._cameras.get(camera)),
),
);
const items: RecordingSegmentsItem[] = [];
results.forEach((segments, cameraID) => {
segments.forEach((segment) => {
items.push({
id: `${cameraID}/${segment.id}`,
cameraID: cameraID,
start: segment.start_time * 1000,
end: segment.end_time * 1000,
});
});
});
this._recordingSegments.update(items);
this._compressRecordingSegmentsOntoTimeline();
}
/**
* Compress recording segments into recordings shown on the timeline
* background.
*/
protected _compressRecordingSegmentsOntoTimeline(): void {
if (!this._recordingSegments.length) {
return;
}
// Delete all the existing background.
this._dataset.remove(
this._dataset.get({
filter: (item) => item.type === 'background',
}),
);
const convertToRecording = (
segment: RecordingSegmentsItem,
): FrigateCardTimelineItem => {
return {
id: `recording-${segment.cameraID}-${segment.id}`,
group: segment.cameraID,
start: segment.start,
end: segment.end,
content: ' ',
type: 'background',
};
};
// Iterate through the segments least to most recent, effectively joining
// segments together that are within a certain tolerance to create large
// blocks that are visualized on the timeline as recordings.
const recordings: FrigateCardTimelineItem[] = [];
this._cameras.forEach((_, cameraID) => {
const segments = this._recordingSegments.get({
filter: (item) => item.cameraID === cameraID,
order: sortSegmentsOldestToYoungest,
});
let current: RecordingSegmentsItem | null = null;
for (let i = 0; i < segments.length; ++i) {
const item = segments[i];
if (!current) {
current = { ...item };
} else if (current.end + RECORDING_SEGMENT_TOLERANCE * 1000 >= item.start) {
current.end = item.end;
} else {
recordings.push(convertToRecording(current));
current = null;
}
if (i === segments.length - 1 && current) {
recordings.push(convertToRecording(current));
}
}
});
this._dataset.update(recordings);
}
/**
* Fetch recording summary.
* @param hass The HomeAssistant object.
*/
protected async _fetchRecordingSummary(hass: ExtendedHomeAssistant): Promise<void> {
const storeRecordingSummary = async (
cameraID: string,
cameraConfig: CameraConfig,
): Promise<void> => {
if (!cameraConfig.frigate.camera_name) {
return;
}
try {
this._recordingSummary.set(
cameraID,
await getRecordingsSummary(
hass,
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
),
);
} catch (e) {
// Recording failure should not disrupt the rest of the timeline
// experience.
errorToConsole(e as Error);
}
};
await Promise.all(
Array.from(this._cameras.keys()).map(async (cameraID) => {
const cameraConfig = this._cameras.get(cameraID);
if (cameraConfig) {
await storeRecordingSummary(cameraID, cameraConfig);
}
}),
);
}
/**
* Fetch events for the timeline.
* @param element The element to send error events from.
* @param hass The HomeAssistant object.
* @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,
start: Date,
end: Date,
): Promise<void> {
const params: BrowseMediaQueryParameters[] = [];
this._cameras.forEach((cameraConfig, cameraID) => {
(this._mediaType === 'all' ? ['clips', 'snapshots'] : [this._mediaType]).forEach(
(mediaType) => {
if (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE) {
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
before: end.getTime() / 1000,
after: start.getTime() / 1000,
unlimited: true,
mediaType: mediaType as 'clips' | 'snapshots',
});
if (param) {
params.push(param);
}
}
},
);
});
if (!params.length) {
return;
}
let results: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>;
try {
results = await multipleBrowseMediaQuery(hass, params);
} catch (e) {
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
for (const result of results.values()) {
this._addMediaSource(result);
}
}
}
+7 -7
View File
@@ -23,12 +23,12 @@ export interface ViewParameters extends ViewEvolveParameters {
} }
export class View { export class View {
view: FrigateCardView; public view: FrigateCardView;
camera: string; public camera: string;
target: FrigateBrowseMediaSource | null; public target: FrigateBrowseMediaSource | null;
childIndex: number | null; public childIndex: number | null;
previous: View | null; public previous: View | null;
context: ViewContext | null; public context: ViewContext | null;
constructor(params: ViewParameters) { constructor(params: ViewParameters) {
this.view = params.view; this.view = params.view;
@@ -162,7 +162,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', 'media'].includes(this.view); return ['clip', 'snapshot', 'media', 'recording'].includes(this.view);
} }
/** /**