Merge pull request #838 from dermotduffy/mini-timeline3
Add a mini-timeline under `live` and `media_viewer` views
This commit is contained in:
@@ -395,6 +395,23 @@ live:
|
|||||||
| `style` | `chevrons` | :white_check_mark: | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . |
|
| `style` | `chevrons` | :white_check_mark: | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . |
|
||||||
| `size` | 48 | :white_check_mark: | The size of the next/previous controls in pixels. Must be >= `20`. |
|
| `size` | 48 | :white_check_mark: | The size of the next/previous controls in pixels. Must be >= `20`. |
|
||||||
|
|
||||||
|
#### Live Controls: Mini Timeline
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
live:
|
||||||
|
controls:
|
||||||
|
timeline:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `window_seconds` | `3600` | :white_check_mark: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
|
||||||
|
| `clustering_threshold` | `3` | :white_check_mark: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.|
|
||||||
|
| `media` | `all` | :white_check_mark: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||||
|
| `show_recordings` | `true` | :white_check_mark: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
|
||||||
|
|
||||||
<a name="live-controls-title"></a>
|
<a name="live-controls-title"></a>
|
||||||
|
|
||||||
#### Live Controls: Title
|
#### Live Controls: Title
|
||||||
@@ -470,6 +487,23 @@ media_viewer:
|
|||||||
| `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.|
|
| `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.|
|
||||||
| `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.|
|
| `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.|
|
||||||
|
|
||||||
|
#### Media Viewer Controls: Mini Timeline
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
media_viewer:
|
||||||
|
controls:
|
||||||
|
timeline:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
|
||||||
|
| `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.|
|
||||||
|
| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||||
|
| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
|
||||||
|
|
||||||
#### Media Viewer Controls: Title
|
#### Media Viewer Controls: Title
|
||||||
|
|
||||||
All configuration is under:
|
All configuration is under:
|
||||||
@@ -1340,6 +1374,12 @@ live:
|
|||||||
show_favorite_control: true
|
show_favorite_control: true
|
||||||
show_timeline_control: true
|
show_timeline_control: true
|
||||||
mode: none
|
mode: none
|
||||||
|
timeline:
|
||||||
|
mode: none
|
||||||
|
clustering_threshold: 3
|
||||||
|
media: all
|
||||||
|
show_recordings: true
|
||||||
|
window_seconds: 3600
|
||||||
title:
|
title:
|
||||||
mode: popup-bottom-right
|
mode: popup-bottom-right
|
||||||
duration_seconds: 2
|
duration_seconds: 2
|
||||||
@@ -1390,6 +1430,12 @@ media_viewer:
|
|||||||
show_details: false
|
show_details: false
|
||||||
show_favorite_control: true
|
show_favorite_control: true
|
||||||
show_timeline_control: true
|
show_timeline_control: true
|
||||||
|
timeline:
|
||||||
|
mode: none
|
||||||
|
clustering_threshold: 3
|
||||||
|
media: all
|
||||||
|
show_recordings: true
|
||||||
|
window_seconds: 3600
|
||||||
title:
|
title:
|
||||||
mode: popup-bottom-right
|
mode: popup-bottom-right
|
||||||
duration_seconds: 2
|
duration_seconds: 2
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"custom-card-helpers": "^1.9.0",
|
"custom-card-helpers": "^1.9.0",
|
||||||
"date-fns": "^2.29.2",
|
"date-fns": "^2.29.2",
|
||||||
|
"date-fns-tz": "^1.3.7",
|
||||||
"embla-carousel": "^7.0.2",
|
"embla-carousel": "^7.0.2",
|
||||||
"embla-carousel-wheel-gestures": "^3.0.0",
|
"embla-carousel-wheel-gestures": "^3.0.0",
|
||||||
"home-assistant-js-websocket": "^8.0.0",
|
"home-assistant-js-websocket": "^8.0.0",
|
||||||
|
|||||||
+15
-3
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -105,7 +105,17 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
* @param index Slide number.
|
* @param index Slide number.
|
||||||
*/
|
*/
|
||||||
public carouselScrollTo(index: number): void {
|
public carouselScrollTo(index: number): void {
|
||||||
|
const scroll = () =>
|
||||||
this._carousel?.scrollTo(index, this.transitionEffect === 'none');
|
this._carousel?.scrollTo(index, this.transitionEffect === 'none');
|
||||||
|
// This ensures scrolling can work on initial render when the carousel may
|
||||||
|
// not yet exist.
|
||||||
|
if (this._carousel) {
|
||||||
|
scroll();
|
||||||
|
} else {
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
scroll();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+14
-7
@@ -61,13 +61,14 @@ import {
|
|||||||
import { dispatchErrorMessageEvent } from './message.js';
|
import { dispatchErrorMessageEvent } from './message.js';
|
||||||
import './next-prev-control.js';
|
import './next-prev-control.js';
|
||||||
import './title-control.js';
|
import './title-control.js';
|
||||||
import './surround-thumbnails';
|
import './surround.js';
|
||||||
import '../patches/ha-camera-stream';
|
import '../patches/ha-camera-stream';
|
||||||
import { EmblaCarouselPlugins } from './carousel.js';
|
import { EmblaCarouselPlugins } from './carousel.js';
|
||||||
import { renderTask } from '../utils/task.js';
|
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 &&
|
||||||
@@ -209,13 +213,16 @@ export class FrigateCardLive extends LitElement {
|
|||||||
// is received when the card is in the background).
|
// is received when the card is in the background).
|
||||||
const result = html`${keyed(
|
const result = html`${keyed(
|
||||||
this._renderKey,
|
this._renderKey,
|
||||||
html`<frigate-card-surround-thumbnails
|
html`<frigate-card-surround
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.config=${config.controls.thumbnails}
|
.fetch=${true}
|
||||||
|
.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;
|
||||||
@@ -245,7 +252,7 @@ export class FrigateCardLive extends LitElement {
|
|||||||
.liveOverrides=${this.liveOverrides}
|
.liveOverrides=${this.liveOverrides}
|
||||||
>
|
>
|
||||||
</frigate-card-live-carousel>
|
</frigate-card-live-carousel>
|
||||||
</frigate-card-surround-thumbnails>`,
|
</frigate-card-surround>`,
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
this._messageReceivedPostRender = false;
|
this._messageReceivedPostRender = false;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
|
import { customElement } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { FrigateCardDrawer } from './drawer.js';
|
||||||
|
|
||||||
|
import './drawer.js';
|
||||||
|
|
||||||
|
import surroundBasicStyle from '../scss/surround-basic.scss';
|
||||||
|
|
||||||
|
interface FrigateCardDrawerOpen {
|
||||||
|
drawer: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-surround-basic')
|
||||||
|
export class FrigateCardSurroundBasic extends LitElement {
|
||||||
|
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef();
|
||||||
|
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef();
|
||||||
|
protected _boundDrawerHandler = this._drawerHandler.bind(this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component connected callback.
|
||||||
|
*/
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
||||||
|
this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component disconnected callback.
|
||||||
|
*/
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
||||||
|
this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _drawerHandler(ev: Event) {
|
||||||
|
const drawer = (ev as CustomEvent<FrigateCardDrawerOpen>).detail.drawer;
|
||||||
|
const open = ev.type.endsWith(':open');
|
||||||
|
if (drawer === 'left' && this._refDrawerLeft.value) {
|
||||||
|
this._refDrawerLeft.value.open = open;
|
||||||
|
} else if (drawer === 'right' && this._refDrawerRight.value) {
|
||||||
|
this._refDrawerRight.value.open = open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master render method.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html` <slot name="above"></slot>
|
||||||
|
<slot></slot>
|
||||||
|
<frigate-card-drawer ${ref(this._refDrawerLeft)} location="left">
|
||||||
|
<slot name="left"></slot>
|
||||||
|
</frigate-card-drawer>
|
||||||
|
<frigate-card-drawer ${ref(this._refDrawerRight)} location="right">
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</frigate-card-drawer>
|
||||||
|
<slot name="below"></slot>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return compiled CSS styles.
|
||||||
|
*/
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(surroundBasicStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'frigate-card-surround-basic': FrigateCardSurroundBasic;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import {
|
|
||||||
CSSResultGroup,
|
|
||||||
html,
|
|
||||||
LitElement,
|
|
||||||
PropertyValues,
|
|
||||||
TemplateResult,
|
|
||||||
unsafeCSS,
|
|
||||||
} from 'lit';
|
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
|
||||||
import surroundThumbnailsStyle from '../scss/surround.scss';
|
|
||||||
import {
|
|
||||||
BrowseMediaQueryParameters,
|
|
||||||
CameraConfig,
|
|
||||||
ExtendedHomeAssistant,
|
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
FrigateCardError,
|
|
||||||
FrigateCardView,
|
|
||||||
ThumbnailsControlConfig,
|
|
||||||
} from '../types.js';
|
|
||||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
|
||||||
import {
|
|
||||||
getFirstTrueMediaChildIndex,
|
|
||||||
multipleBrowseMediaQueryMerged,
|
|
||||||
} from '../utils/ha/browse-media';
|
|
||||||
import { View } from '../view.js';
|
|
||||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
|
||||||
import './surround.js';
|
|
||||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
|
||||||
|
|
||||||
interface ThumbnailViewContext {
|
|
||||||
// Whetherr or not to fetch thumbnails.
|
|
||||||
fetch?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'view' {
|
|
||||||
interface ViewContext {
|
|
||||||
thumbnails?: ThumbnailViewContext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement('frigate-card-surround-thumbnails')
|
|
||||||
export class FrigateCardSurround extends LitElement {
|
|
||||||
@property({ attribute: false })
|
|
||||||
public hass?: ExtendedHomeAssistant;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public view?: Readonly<View>;
|
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
|
||||||
public config?: ThumbnailsControlConfig;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public targetView?: FrigateCardView;
|
|
||||||
|
|
||||||
@property({ attribute: true, type: Boolean })
|
|
||||||
public fetch?: boolean;
|
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
|
||||||
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cameras?: Map<string, CameraConfig>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch thumbnail media when a target is not specified in the view (e.g. for
|
|
||||||
* the live view).
|
|
||||||
* @param param Task parameters.
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
protected async _fetchMedia(): Promise<void> {
|
|
||||||
if (
|
|
||||||
!this.fetch ||
|
|
||||||
!this.hass ||
|
|
||||||
!this.view ||
|
|
||||||
!this.config ||
|
|
||||||
this.config.mode === 'none' ||
|
|
||||||
this.view.target ||
|
|
||||||
!this.browseMediaParams ||
|
|
||||||
!(this.view.context?.thumbnails?.fetch ?? true)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let parent: FrigateBrowseMediaSource | null;
|
|
||||||
try {
|
|
||||||
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
|
|
||||||
} catch (e) {
|
|
||||||
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
|
||||||
}
|
|
||||||
if (getFirstTrueMediaChildIndex(parent) !== null) {
|
|
||||||
this.view
|
|
||||||
?.evolve({
|
|
||||||
...(this.targetView && { view: this.targetView }),
|
|
||||||
target: parent,
|
|
||||||
childIndex: null,
|
|
||||||
|
|
||||||
// Don't carry over history of this 'empty' view.
|
|
||||||
previous: null,
|
|
||||||
})
|
|
||||||
.dispatchChangeEvent(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if a drawer is being used.
|
|
||||||
* @returns `true` if a drawer is used, `false` otherwise.
|
|
||||||
*/
|
|
||||||
protected _hasDrawer(): boolean {
|
|
||||||
return !!this.config && ['left', 'right'].includes(this.config.mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called before each update.
|
|
||||||
*/
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
|
||||||
// Once the component will certainly update, dispatch a media request. Only
|
|
||||||
// do so if properties relevant to the request have changed (as per their
|
|
||||||
// hasChanged).
|
|
||||||
if (
|
|
||||||
['view', 'targetView', 'fetch', 'browseMediaParams'].some((prop) =>
|
|
||||||
changedProperties.has(prop),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this._fetchMedia();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
|
||||||
if (!this.hass || !this.view || !this.config) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => {
|
|
||||||
// The event catch/re-dispatch below protect encapsulation: Catches the
|
|
||||||
// request to view thumbnails and re-dispatches a request to open the drawer
|
|
||||||
// (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
|
|
||||||
// <frigate-card-surround> .
|
|
||||||
if (this.config && this._hasDrawer()) {
|
|
||||||
dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
|
|
||||||
drawer: this.config.mode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return html` <frigate-card-surround
|
|
||||||
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
|
|
||||||
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
|
||||||
>
|
|
||||||
${this.config && this.config.mode !== 'none'
|
|
||||||
? html` <frigate-card-thumbnail-carousel
|
|
||||||
slot=${this.config.mode}
|
|
||||||
.hass=${this.hass}
|
|
||||||
.config=${this.config}
|
|
||||||
.view=${this.view}
|
|
||||||
.target=${this.view.target}
|
|
||||||
.selected=${this.view.childIndex}
|
|
||||||
.cameras=${this.cameras}
|
|
||||||
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
|
||||||
@frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
|
||||||
// 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).
|
|
||||||
this.view
|
|
||||||
?.evolve({
|
|
||||||
view: this.targetView || 'media',
|
|
||||||
target: ev.detail.target,
|
|
||||||
childIndex: ev.detail.childIndex,
|
|
||||||
context: null,
|
|
||||||
})
|
|
||||||
.dispatchChangeEvent(ev.composedPath()[0]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</frigate-card-thumbnail-carousel>`
|
|
||||||
: ''}
|
|
||||||
<slot></slot>
|
|
||||||
</frigate-card-surround>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return compiled CSS styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
|
||||||
return unsafeCSS(surroundThumbnailsStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
'frigate-card-surround-thumbnails': FrigateCardSurround;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+194
-38
@@ -1,48 +1,139 @@
|
|||||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
import {
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
CSSResultGroup,
|
||||||
import { customElement } from 'lit/decorators.js';
|
html,
|
||||||
|
LitElement,
|
||||||
import { FrigateCardDrawer } from './drawer.js';
|
PropertyValues,
|
||||||
|
TemplateResult,
|
||||||
import './drawer.js';
|
unsafeCSS,
|
||||||
|
} from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import surroundStyle from '../scss/surround.scss';
|
import surroundStyle from '../scss/surround.scss';
|
||||||
|
import {
|
||||||
|
BrowseMediaQueryParameters,
|
||||||
|
CameraConfig,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
FrigateBrowseMediaSource,
|
||||||
|
FrigateCardError,
|
||||||
|
MiniTimelineControlConfig,
|
||||||
|
ThumbnailsControlConfig,
|
||||||
|
} from '../types.js';
|
||||||
|
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
|
import {
|
||||||
|
getFirstTrueMediaChildIndex,
|
||||||
|
multipleBrowseMediaQueryMerged,
|
||||||
|
} from '../utils/ha/browse-media';
|
||||||
|
import { TimelineDataManager } from '../utils/timeline-data-manager';
|
||||||
|
import { View } from '../view.js';
|
||||||
|
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||||
|
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||||
|
|
||||||
interface FrigateCardDrawerOpen {
|
import './surround-basic.js';
|
||||||
drawer: 'left' | 'right';
|
import './timeline-core.js';
|
||||||
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
|
||||||
|
interface ThumbnailViewContext {
|
||||||
|
// Whether or not to fetch thumbnails.
|
||||||
|
fetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'view' {
|
||||||
|
interface ViewContext {
|
||||||
|
thumbnails?: ThumbnailViewContext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-surround')
|
@customElement('frigate-card-surround')
|
||||||
export class FrigateCardSurround extends LitElement {
|
export class FrigateCardSurround extends LitElement {
|
||||||
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef();
|
@property({ attribute: false })
|
||||||
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef();
|
public hass?: ExtendedHomeAssistant;
|
||||||
protected _boundDrawerHandler = this._drawerHandler.bind(this);
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public thumbnailConfig?: ThumbnailsControlConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public timelineConfig?: MiniTimelineControlConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public inBackground?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public fetch = false;
|
||||||
|
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameras?: Map<string, CameraConfig>;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public timelineDataManager?: TimelineDataManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component connected callback.
|
* Fetch thumbnail media when a target is not specified in the view (e.g. for
|
||||||
|
* the live view).
|
||||||
|
* @param param Task parameters.
|
||||||
|
* @returns
|
||||||
*/
|
*/
|
||||||
connectedCallback(): void {
|
protected async _fetchMedia(): Promise<void> {
|
||||||
super.connectedCallback();
|
if (
|
||||||
this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
!this.fetch ||
|
||||||
this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
this.inBackground ||
|
||||||
|
!this.hass ||
|
||||||
|
!this.view ||
|
||||||
|
this.view.target ||
|
||||||
|
!this.thumbnailConfig ||
|
||||||
|
this.thumbnailConfig.mode === 'none' ||
|
||||||
|
!this.browseMediaParams ||
|
||||||
|
!(this.view.context?.thumbnails?.fetch ?? true)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parent: FrigateBrowseMediaSource | null;
|
||||||
|
try {
|
||||||
|
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
|
||||||
|
} catch (e) {
|
||||||
|
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
||||||
|
}
|
||||||
|
if (getFirstTrueMediaChildIndex(parent) !== null) {
|
||||||
|
this.view
|
||||||
|
?.evolve({
|
||||||
|
target: parent,
|
||||||
|
childIndex: null,
|
||||||
|
|
||||||
|
// Don't carry over history of this 'empty' view.
|
||||||
|
previous: null,
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component disconnected callback.
|
* Determine if a drawer is being used.
|
||||||
|
* @returns `true` if a drawer is used, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
disconnectedCallback(): void {
|
protected _hasDrawer(): boolean {
|
||||||
super.disconnectedCallback();
|
return (
|
||||||
this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
!!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode)
|
||||||
this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _drawerHandler(ev: Event) {
|
/**
|
||||||
const drawer = (ev as CustomEvent<FrigateCardDrawerOpen>).detail.drawer;
|
* Called before each update.
|
||||||
const open = ev.type.endsWith(':open');
|
*/
|
||||||
if (drawer === 'left' && this._refDrawerLeft.value) {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
this._refDrawerLeft.value.open = open;
|
// Once the component will certainly update, dispatch a media request. Only
|
||||||
} else if (drawer === 'right' && this._refDrawerRight.value) {
|
// do so if properties relevant to the request have changed (as per their
|
||||||
this._refDrawerRight.value.open = open;
|
// hasChanged).
|
||||||
|
if (
|
||||||
|
['view', 'fetch', 'browseMediaParams', 'inBackground'].some((prop) =>
|
||||||
|
changedProperties.has(prop),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this._fetchMedia();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,15 +142,80 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
return html` <slot name="above"></slot>
|
if (!this.hass || !this.view || !this.thumbnailConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => {
|
||||||
|
// The event catch/re-dispatch below protect encapsulation: Catches the
|
||||||
|
// request to view thumbnails and re-dispatches a request to open the drawer
|
||||||
|
// (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
|
||||||
|
// <frigate-card-surround> .
|
||||||
|
if (this.thumbnailConfig && this._hasDrawer()) {
|
||||||
|
dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
|
||||||
|
drawer: this.thumbnailConfig.mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return html` <frigate-card-surround-basic
|
||||||
|
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
|
||||||
|
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||||
|
>
|
||||||
|
${this.thumbnailConfig &&
|
||||||
|
this.thumbnailConfig.mode !== 'none' &&
|
||||||
|
!this.inBackground
|
||||||
|
? html` <frigate-card-thumbnail-carousel
|
||||||
|
slot=${this.thumbnailConfig.mode}
|
||||||
|
.hass=${this.hass}
|
||||||
|
.config=${this.thumbnailConfig}
|
||||||
|
.view=${this.view}
|
||||||
|
.target=${this.view.target}
|
||||||
|
.cameras=${this.cameras}
|
||||||
|
selected=${ifDefined(this.view.childIndex ?? undefined)}
|
||||||
|
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||||
|
@frigate-card:thumbnail-carousel:tap=${(
|
||||||
|
ev: CustomEvent<ThumbnailCarouselTap>,
|
||||||
|
) => {
|
||||||
|
const child: FrigateBrowseMediaSource | null =
|
||||||
|
ev.detail.target?.children?.[ev.detail.childIndex] ?? null;
|
||||||
|
if (child) {
|
||||||
|
this.view
|
||||||
|
?.evolve({
|
||||||
|
view: this.view.is('recording') ? 'recording' : 'media',
|
||||||
|
target: ev.detail.target,
|
||||||
|
childIndex: ev.detail.childIndex,
|
||||||
|
...(child.frigate?.cameraID && {
|
||||||
|
camera: child.frigate?.cameraID,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.removeContext('timeline')
|
||||||
|
// 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).
|
||||||
|
.dispatchChangeEvent(ev.composedPath()[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</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-drawer ${ref(this._refDrawerLeft)} location="left">
|
</frigate-card-surround-basic>`;
|
||||||
<slot name="left"></slot>
|
|
||||||
</frigate-card-drawer>
|
|
||||||
<frigate-card-drawer ${ref(this._refDrawerRight)} location="right">
|
|
||||||
<slot name="right"></slot>
|
|
||||||
</frigate-card-drawer>
|
|
||||||
<slot name="below"></slot>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +228,6 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
"frigate-card-surround": FrigateCardSurround
|
'frigate-card-surround': FrigateCardSurround;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } 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 { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||||
@@ -59,8 +59,8 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public config?: ThumbnailsControlConfig;
|
public config?: ThumbnailsControlConfig;
|
||||||
|
|
||||||
@state()
|
@property({ attribute: false, type: Number, reflect: true })
|
||||||
protected _selected: number | null = null;
|
public selected?: number;
|
||||||
|
|
||||||
protected _carouselOptions?: EmblaOptionsType;
|
protected _carouselOptions?: EmblaOptionsType;
|
||||||
protected _carouselPlugins: EmblaPluginType[] = [
|
protected _carouselPlugins: EmblaPluginType[] = [
|
||||||
@@ -76,15 +76,6 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
set selected(selected: number | null) {
|
|
||||||
this._selected = selected;
|
|
||||||
this.style.setProperty(
|
|
||||||
'--frigate-card-carousel-thumbnail-opacity',
|
|
||||||
selected === null ? '1.0' : '0.4',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle gallery resize.
|
* Handle gallery resize.
|
||||||
*/
|
*/
|
||||||
@@ -116,7 +107,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
return {
|
return {
|
||||||
containScroll: 'keepSnaps',
|
containScroll: 'keepSnaps',
|
||||||
dragFree: true,
|
dragFree: true,
|
||||||
startIndex: this._selected ?? 0,
|
startIndex: this.selected ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -155,6 +146,13 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('selected')) {
|
||||||
|
this.style.setProperty(
|
||||||
|
'--frigate-card-carousel-thumbnail-opacity',
|
||||||
|
this.selected === undefined ? '1.0' : '0.4',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this._carouselOptions) {
|
if (!this._carouselOptions) {
|
||||||
// Want to set the initial carousel options just before the first render
|
// Want to set the initial carousel options just before the first render
|
||||||
// in order to get the startIndex correct in the options. It is not safe
|
// in order to get the startIndex correct in the options. It is not safe
|
||||||
@@ -171,10 +169,10 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
updated(changedProperties: PropertyValues): void {
|
updated(changedProperties: PropertyValues): void {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
||||||
if (changedProperties.has('_selected')) {
|
if (changedProperties.has('selected')) {
|
||||||
this.updateComplete.then(() => {
|
this.updateComplete.then(() => {
|
||||||
if (this._selected !== null) {
|
if (this.selected !== undefined) {
|
||||||
this._refCarousel.value?.carouselScrollTo(this._selected);
|
this._refCarousel.value?.carouselScrollTo(this.selected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -200,7 +198,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
embla__slide: true,
|
embla__slide: true,
|
||||||
'slide-selected': this._selected === childIndex,
|
'slide-selected': this.selected === childIndex,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null;
|
const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null;
|
||||||
@@ -209,6 +207,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}
|
||||||
|
|||||||
+82
-20
@@ -2,25 +2,28 @@ 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 { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
|
import { errorToConsole, prettifyTitle } from '../utils/basic.js';
|
||||||
|
import { retainEvent } from '../utils/frigate.js';
|
||||||
|
import { getEventDurationString } from '../utils/frigate.js';
|
||||||
|
import { renderTask } from '../utils/task.js';
|
||||||
|
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||||
|
import { View } from '../view.js';
|
||||||
|
import { MediaSeek } from './viewer.js';
|
||||||
|
import { TaskStatus } from '@lit-labs/task';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
FrigateEvent,
|
FrigateEvent,
|
||||||
FrigateRecording,
|
FrigateRecording,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
|
||||||
import { errorToConsole, prettifyTitle } from '../utils/basic.js';
|
|
||||||
import { retainEvent } from '../utils/frigate.js';
|
|
||||||
import { getEventDurationString } from '../utils/ha/browse-media.js';
|
|
||||||
import { renderTask } from '../utils/task.js';
|
|
||||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
|
||||||
import { View } from '../view.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;
|
||||||
|
|
||||||
@@ -36,24 +39,63 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
|||||||
this,
|
this,
|
||||||
() => this.hass,
|
() => this.hass,
|
||||||
() => this.thumbnail,
|
() => this.thumbnail,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only load thumbnails on view in case there is a very large number of them.
|
||||||
|
protected _intersectionObserver: IntersectionObserver;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._intersectionObserver = new IntersectionObserver(
|
||||||
|
this._intersectionHandler.bind(this),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component connected callback.
|
||||||
|
*/
|
||||||
|
connectedCallback(): void {
|
||||||
|
this._intersectionObserver.observe(this);
|
||||||
|
super.connectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component disconnected callback.
|
||||||
|
*/
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the live view intersects with the viewport.
|
||||||
|
* @param entries The IntersectionObserverEntry entries (should be only 1).
|
||||||
|
*/
|
||||||
|
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||||
|
if (
|
||||||
|
this._embedThumbnailTask.status === TaskStatus.INITIAL &&
|
||||||
|
entries.some((entry) => entry.isIntersecting)
|
||||||
|
) {
|
||||||
|
this._embedThumbnailTask.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
return html`
|
const imageOff = html`<ha-icon
|
||||||
${this.thumbnail
|
icon="mdi:image-off"
|
||||||
|
title=${localize('thumbnail.no_thumbnail')}
|
||||||
|
></ha-icon> `;
|
||||||
|
|
||||||
|
return html`${this.thumbnail
|
||||||
? renderTask(
|
? renderTask(
|
||||||
this,
|
this,
|
||||||
this._embedThumbnailTask,
|
this._embedThumbnailTask,
|
||||||
(embeddedThumbnail: string | null) =>
|
(embeddedThumbnail: string | null) =>
|
||||||
embeddedThumbnail
|
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
|
||||||
? html`<img src="${embeddedThumbnail}" />`
|
() => imageOff,
|
||||||
: html``
|
|
||||||
)
|
)
|
||||||
: html`<ha-icon
|
: imageOff} `;
|
||||||
icon="mdi:image-off"
|
|
||||||
title=${localize('thumbnail.no_thumbnail')}
|
|
||||||
></ha-icon> `}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
static get styles(): CSSResult {
|
||||||
@@ -86,6 +128,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 +146,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 +168,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 +215,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 +320,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
|
||||||
@@ -286,6 +345,9 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
.removeContext('timeline')
|
.removeContext('timeline')
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
} else if (recording) {
|
} else if (recording) {
|
||||||
|
// Specifically reset the media target/childIndex, as we cannot
|
||||||
|
// 'select' an hour in the timeline rather we set the window to
|
||||||
|
// matching values.
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
view: 'timeline',
|
view: 'timeline',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+19
-1293
File diff suppressed because it is too large
Load Diff
+44
-16
@@ -51,10 +51,30 @@ import {
|
|||||||
import './next-prev-control.js';
|
import './next-prev-control.js';
|
||||||
import './title-control.js';
|
import './title-control.js';
|
||||||
import '../patches/ha-hls-player';
|
import '../patches/ha-hls-player';
|
||||||
import './surround-thumbnails';
|
import './surround.js';
|
||||||
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.
|
||||||
@@ -111,10 +134,13 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
return renderProgressIndicator();
|
return renderProgressIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
return html` <frigate-card-surround-thumbnails
|
return html` <frigate-card-surround
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.config=${this.viewerConfig.controls.thumbnails}
|
.fetch=${false}
|
||||||
|
.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
|
||||||
@@ -125,7 +151,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
>
|
>
|
||||||
</frigate-card-viewer-carousel>
|
</frigate-card-viewer-carousel>
|
||||||
</frigate-card-surround-thumbnails>`;
|
</frigate-card-surround>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,7 +228,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 +241,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 +695,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 +747,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);
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
|||||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
|
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
|
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||||
|
`${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA =
|
||||||
|
`${CONF_MEDIA_VIEWER}.controls.timeline.media` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
|
||||||
|
`${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS =
|
||||||
|
`${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
|
||||||
|
`${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const;
|
||||||
|
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
|
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
|
||||||
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
|
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
|
||||||
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
|
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
|
||||||
@@ -115,6 +126,16 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
|||||||
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
|
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
|
||||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||||
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
|
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||||
|
`${CONF_LIVE}.control s.timeline.clustering_threshold` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_TIMELINE_MEDIA =
|
||||||
|
`${CONF_LIVE}.controls.timeline.media` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
|
||||||
|
`${CONF_LIVE}.controls.timeline.mode` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS =
|
||||||
|
`${CONF_LIVE}.controls.timeline.show_recordings` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
|
||||||
|
`${CONF_LIVE}.controls.timeline.window_seconds` as const;
|
||||||
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
||||||
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
||||||
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
||||||
|
|||||||
+103
-6
@@ -54,6 +54,11 @@ import {
|
|||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||||
CONF_LIVE_DRAGGABLE,
|
CONF_LIVE_DRAGGABLE,
|
||||||
@@ -76,6 +81,11 @@ import {
|
|||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
|
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
|
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
|
||||||
CONF_MEDIA_VIEWER_DRAGGABLE,
|
CONF_MEDIA_VIEWER_DRAGGABLE,
|
||||||
@@ -136,8 +146,10 @@ const MENU_CAMERAS_WEBRTC = 'cameras.webrtc';
|
|||||||
const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls';
|
const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls';
|
||||||
const MENU_IMAGE_LAYOUT = 'image.layout';
|
const MENU_IMAGE_LAYOUT = 'image.layout';
|
||||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
const MENU_LIVE_CONTROLS = 'live.controls';
|
||||||
|
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
||||||
const MENU_LIVE_LAYOUT = 'live.layout';
|
const MENU_LIVE_LAYOUT = 'live.layout';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
|
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
|
||||||
|
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
|
||||||
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
|
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
|
||||||
const MENU_TIMELINE_CONTROLS = 'timeline.controls';
|
const MENU_TIMELINE_CONTROLS = 'timeline.controls';
|
||||||
const MENU_OPTIONS = 'options';
|
const MENU_OPTIONS = 'options';
|
||||||
@@ -421,6 +433,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
{ value: 'fill', label: localize('config.common.layout.fits.fill') },
|
{ value: 'fill', label: localize('config.common.layout.fits.fill') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected _miniTimelineModes: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{ value: 'none', label: localize('config.timeline.mini.modes.none') },
|
||||||
|
{ value: 'above', label: localize('config.timeline.mini.modes.above') },
|
||||||
|
{ value: 'below', label: localize('config.timeline.mini.modes.below') },
|
||||||
|
];
|
||||||
|
|
||||||
public setConfig(config: RawFrigateCardConfig): void {
|
public setConfig(config: RawFrigateCardConfig): void {
|
||||||
// Note: This does not use Zod to parse the configuration, so it may be
|
// Note: This does not use Zod to parse the configuration, so it may be
|
||||||
// partially or completely invalid. It's more useful to have a partially
|
// partially or completely invalid. It's more useful to have a partially
|
||||||
@@ -819,6 +838,71 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the core timeline controls (mini or full timeline),
|
||||||
|
* @param configPathWindowSeconds Timeline window config path.
|
||||||
|
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||||
|
* @param configPathTimelineMedia Timeline media config path.
|
||||||
|
* @param configPathShowRecordings Show recordings config path.
|
||||||
|
* @param defaultShowRecordings Default value of show_recordings.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected _renderTimelineCoreControls(
|
||||||
|
configPathWindowSeconds: string,
|
||||||
|
configPathClusteringThreshold: string,
|
||||||
|
configPathTimelineMedia: string,
|
||||||
|
configPathShowRecordings: string,
|
||||||
|
defaultShowRecordings: boolean,
|
||||||
|
): TemplateResult {
|
||||||
|
return html` ${this._renderNumberInput(configPathWindowSeconds, {
|
||||||
|
label: localize(`config.${CONF_TIMELINE_WINDOW_SECONDS}`),
|
||||||
|
})}
|
||||||
|
${this._renderNumberInput(configPathClusteringThreshold, {
|
||||||
|
label: localize(`config.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
|
||||||
|
})}
|
||||||
|
${this._renderOptionSelector(configPathTimelineMedia, this._timelineMediaTypes, {
|
||||||
|
label: localize(`config.${CONF_TIMELINE_MEDIA}`),
|
||||||
|
})}
|
||||||
|
${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
|
||||||
|
label: localize(`config.${CONF_TIMELINE_SHOW_RECORDINGS}`),
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the mini timeline controls.
|
||||||
|
* @param domain The submenu domain.
|
||||||
|
* @param configPathWindowSeconds Timeline window config path.
|
||||||
|
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||||
|
* @param configPathTimelineMedia Timeline media config path.
|
||||||
|
* @param configPathShowRecordings Show recordings config path.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected _renderMiniTimeline(
|
||||||
|
domain: string,
|
||||||
|
configPathMode: string,
|
||||||
|
configPathWindowSeconds: string,
|
||||||
|
configPathClusteringThreshold: string,
|
||||||
|
configPathTimelineMedia: string,
|
||||||
|
configPathShowRecordings: string,
|
||||||
|
): TemplateResult | void {
|
||||||
|
return this._putInSubmenu(
|
||||||
|
domain,
|
||||||
|
true,
|
||||||
|
'config.timeline.mini.options',
|
||||||
|
{ name: 'mdi:chart-gantt' },
|
||||||
|
html` ${this._renderOptionSelector(configPathMode, this._miniTimelineModes, {
|
||||||
|
label: localize('config.timeline.mini.mode'),
|
||||||
|
})}
|
||||||
|
${this._renderTimelineCoreControls(
|
||||||
|
configPathWindowSeconds,
|
||||||
|
configPathClusteringThreshold,
|
||||||
|
configPathTimelineMedia,
|
||||||
|
configPathShowRecordings,
|
||||||
|
frigateCardConfigDefaults.mini_timeline.show_recordings,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a camera section.
|
* Render a camera section.
|
||||||
* @param cameras The full array of cameras.
|
* @param cameras The full array of cameras.
|
||||||
@@ -1312,6 +1396,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
max: 60,
|
max: 60,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
${this._renderMiniTimeline(
|
||||||
|
MENU_LIVE_CONTROLS_TIMELINE,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
|
||||||
|
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
${this._renderMediaLayout(
|
${this._renderMediaLayout(
|
||||||
@@ -1429,6 +1521,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
{ min: 0, max: 60 },
|
{ min: 0, max: 60 },
|
||||||
)}
|
)}
|
||||||
|
${this._renderMiniTimeline(
|
||||||
|
MENU_MEDIA_VIEWER_CONTROLS_TIMELINE,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
|
||||||
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
${this._renderMediaLayout(
|
${this._renderMediaLayout(
|
||||||
@@ -1458,13 +1558,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderOptionSetHeader('timeline')}
|
${this._renderOptionSetHeader('timeline')}
|
||||||
${this._expandedMenus[MENU_OPTIONS] === 'timeline'
|
${this._expandedMenus[MENU_OPTIONS] === 'timeline'
|
||||||
? html` <div class="values">
|
? html` <div class="values">
|
||||||
${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)}
|
${this._renderTimelineCoreControls(
|
||||||
${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)}
|
CONF_TIMELINE_WINDOW_SECONDS,
|
||||||
${this._renderOptionSelector(
|
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
CONF_TIMELINE_MEDIA,
|
CONF_TIMELINE_MEDIA,
|
||||||
this._timelineMediaTypes,
|
|
||||||
)}
|
|
||||||
${this._renderSwitch(
|
|
||||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||||
defaults.timeline.show_recordings,
|
defaults.timeline.show_recordings,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -161,11 +161,11 @@
|
|||||||
"thumbnails": {
|
"thumbnails": {
|
||||||
"mode": "Media Viewer thumbnails mode",
|
"mode": "Media Viewer thumbnails mode",
|
||||||
"modes": {
|
"modes": {
|
||||||
"above": "Thumbnails above the media",
|
"above": "Thumbnails above",
|
||||||
"below": "Thumbnails below the media",
|
"below": "Thumbnails below",
|
||||||
"left": "Thumbnails in a drawer left of the media",
|
"left": "Thumbnails in a drawer to the left",
|
||||||
"none": "No thumbnails",
|
"none": "No thumbnails",
|
||||||
"right": "Thumbnails in a drawer right of the media"
|
"right": "Thumbnails in a drawer to the right"
|
||||||
},
|
},
|
||||||
"show_details": "Show details with thumbnails",
|
"show_details": "Show details with thumbnails",
|
||||||
"show_favorite_control": "Show favorite control on thumbnails",
|
"show_favorite_control": "Show favorite control on thumbnails",
|
||||||
@@ -254,6 +254,15 @@
|
|||||||
"size": "Timeline thumbnails size in pixels"
|
"size": "Timeline thumbnails size in pixels"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mini": {
|
||||||
|
"options": "Mini Timeline",
|
||||||
|
"mode": "Mode",
|
||||||
|
"modes": {
|
||||||
|
"none": "None",
|
||||||
|
"above": "Above",
|
||||||
|
"below": "Below"
|
||||||
|
}
|
||||||
|
},
|
||||||
"media": "The media the timeline displays",
|
"media": "The media the timeline displays",
|
||||||
"medias": {
|
"medias": {
|
||||||
"all": "All media types",
|
"all": "All media types",
|
||||||
@@ -362,7 +371,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",
|
||||||
@@ -371,7 +381,11 @@
|
|||||||
"thumbnail": {
|
"thumbnail": {
|
||||||
"no_thumbnail": "No thumbnail available",
|
"no_thumbnail": "No thumbnail available",
|
||||||
"retain_indefinitely": "Event will be indefinitely retained",
|
"retain_indefinitely": "Event will be indefinitely retained",
|
||||||
"timeline": "See event in timeline"
|
"timeline": "See event/recording in timeline"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"lock": "Lock timeline to a single event",
|
||||||
|
"unlock": "Unlock timeline"
|
||||||
},
|
},
|
||||||
"elements": {
|
"elements": {
|
||||||
"ptz": {
|
"ptz": {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +128,6 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
// Share the screen space with thumbnails that may be above/below.
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// Set the drawer relative to this host.
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// Hide any content outside the main pane (e.g. side drawers) to ensure the
|
||||||
|
// user cannot scroll across to the drawers without opening them.
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted:not([name]) {
|
||||||
|
// Expand the main body to fill available content not otherwise used by the
|
||||||
|
// surround.
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
:host {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
+1
-17
@@ -1,21 +1,5 @@
|
|||||||
:host {
|
:host {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
display: block;
|
||||||
// Share the screen space with thumbnails that may be above/below.
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
// Set the drawer relative to this host.
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
// Hide any content outside the main pane (e.g. side drawers) to ensure the
|
|
||||||
// user cannot scroll across to the drawers without opening them.
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted:not([name]) {
|
|
||||||
// Expand the main body to fill available content not otherwise used by the
|
|
||||||
// surround.
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ ha-icon {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-12
@@ -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;
|
||||||
|
}
|
||||||
+73
-59
@@ -26,6 +26,8 @@ export const THUMBNAIL_WIDTH_MIN = 75;
|
|||||||
* Internal types.
|
* Internal types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type ClipsOrSnapshots = 'clips' | 'snapshots';
|
||||||
|
|
||||||
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
||||||
'live',
|
'live',
|
||||||
'clip',
|
'clip',
|
||||||
@@ -38,6 +40,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',
|
||||||
@@ -568,10 +571,12 @@ export type PictureElements = z.infer<typeof pictureElementsSchema>;
|
|||||||
*/
|
*/
|
||||||
const mediaLayoutConfigSchema = z.object({
|
const mediaLayoutConfigSchema = z.object({
|
||||||
fit: z.enum(['contain', 'cover', 'fill']).optional(),
|
fit: z.enum(['contain', 'cover', 'fill']).optional(),
|
||||||
position: z.object({
|
position: z
|
||||||
|
.object({
|
||||||
x: z.number().min(0).max(100).optional(),
|
x: z.number().min(0).max(100).optional(),
|
||||||
y: z.number().min(0).max(100).optional(),
|
y: z.number().min(0).max(100).optional(),
|
||||||
}).optional(),
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
|
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
|
||||||
|
|
||||||
@@ -655,6 +660,44 @@ 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 timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']);
|
||||||
|
export type TimelineMedia = z.infer<typeof timelineMediaSchema>;
|
||||||
|
|
||||||
|
const timelineCoreConfigSchema = z.object({
|
||||||
|
clustering_threshold: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.default(timelineCoreConfigDefault.clustering_threshold),
|
||||||
|
media: timelineMediaSchema.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 +830,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 +1033,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 +1127,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 +1138,9 @@ const timelineConfigDefault = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const timelineConfigSchema = z
|
|
||||||
.object({
|
const timelineConfigSchema = timelineCoreConfigSchema
|
||||||
clustering_threshold: z
|
.extend({
|
||||||
.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
|
||||||
@@ -1215,6 +1240,7 @@ export const frigateCardConfigDefaults = {
|
|||||||
event_gallery: galleryConfigDefault,
|
event_gallery: galleryConfigDefault,
|
||||||
image: imageConfigDefault,
|
image: imageConfigDefault,
|
||||||
timeline: timelineConfigDefault,
|
timeline: timelineConfigDefault,
|
||||||
|
mini_timeline: timelineCoreConfigDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuButtonSchema = z.discriminatedUnion('type', [
|
const menuButtonSchema = z.discriminatedUnion('type', [
|
||||||
@@ -1347,31 +1373,12 @@ interface BrowseMediaSource {
|
|||||||
children?: BrowseMediaSource[] | null;
|
children?: BrowseMediaSource[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FrigateEvent {
|
|
||||||
camera: string;
|
|
||||||
end_time?: number;
|
|
||||||
false_positive: boolean;
|
|
||||||
has_clip: boolean;
|
|
||||||
has_snapshot: boolean;
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
start_time: number;
|
|
||||||
top_score: number;
|
|
||||||
zones: string[];
|
|
||||||
retain_indefinitely?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,9 +1386,28 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
|||||||
frigate?: {
|
frigate?: {
|
||||||
event?: FrigateEvent;
|
event?: FrigateEvent;
|
||||||
recording?: FrigateRecording;
|
recording?: FrigateRecording;
|
||||||
|
cameraID?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const frigateEventSchema = z.object({
|
||||||
|
camera: z.string(),
|
||||||
|
end_time: z.number().nullable(),
|
||||||
|
false_positive: z.boolean().nullable(),
|
||||||
|
has_clip: z.boolean(),
|
||||||
|
has_snapshot: z.boolean(),
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
start_time: z.number(),
|
||||||
|
top_score: z.number(),
|
||||||
|
zones: z.string().array(),
|
||||||
|
retain_indefinitely: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type FrigateEvent = z.infer<typeof frigateEventSchema>;
|
||||||
|
|
||||||
|
export const frigateEventsSchema = frigateEventSchema.array();
|
||||||
|
export type FrigateEvents = z.infer<typeof frigateEventsSchema>;
|
||||||
|
|
||||||
export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.lazy(
|
export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.lazy(
|
||||||
() =>
|
() =>
|
||||||
z.object({
|
z.object({
|
||||||
@@ -1396,19 +1422,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.
|
|||||||
children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(),
|
children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(),
|
||||||
frigate: z
|
frigate: z
|
||||||
.object({
|
.object({
|
||||||
event: z.object({
|
event: frigateEventSchema,
|
||||||
camera: z.string(),
|
|
||||||
end_time: z.number().nullable(),
|
|
||||||
false_positive: z.boolean().nullable(),
|
|
||||||
has_clip: z.boolean(),
|
|
||||||
has_snapshot: z.boolean(),
|
|
||||||
id: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
start_time: z.number(),
|
|
||||||
top_score: z.number(),
|
|
||||||
zones: z.string().array(),
|
|
||||||
retain_indefinitely: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { FrigateCardError } from '../types';
|
import { FrigateCardError } from '../types';
|
||||||
|
|
||||||
@@ -85,3 +86,28 @@ export function errorToConsole(e: Error, func?: CallableFunction): void {
|
|||||||
export const isHoverableDevice = (): boolean => window.matchMedia(
|
export const isHoverableDevice = (): boolean => window.matchMedia(
|
||||||
'(hover: hover) and (pointer: fine)',
|
'(hover: hover) and (pointer: fine)',
|
||||||
).matches;
|
).matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date object to RFC3339.
|
||||||
|
* @param date A Date object.
|
||||||
|
* @returns A date and time.
|
||||||
|
*/
|
||||||
|
export const formatDateAndTime = (date: Date): string => {
|
||||||
|
return format(date, 'yyyy-MM-dd HH:mm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a function in idle periods. If idle callbacks are not supported (e.g.
|
||||||
|
* Safari) the callback is run immediately.
|
||||||
|
* @param func The function to call.
|
||||||
|
* @param timeout The maximum number of seconds to wait.
|
||||||
|
*/
|
||||||
|
export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => {
|
||||||
|
if (window.requestIdleCallback) {
|
||||||
|
window.requestIdleCallback(func, {
|
||||||
|
...(timeout && { timeout: timeout})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
func();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
+158
-19
@@ -1,7 +1,21 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
|
||||||
|
import differenceInHours from 'date-fns/differenceInHours';
|
||||||
|
import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||||
|
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||||
|
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { CameraConfig, ExtendedHomeAssistant, FrigateCardError } from '../types';
|
import {
|
||||||
|
BrowseRecordingQueryParameters,
|
||||||
|
ClipsOrSnapshots,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
FrigateCardError,
|
||||||
|
FrigateEvent,
|
||||||
|
FrigateEvents,
|
||||||
|
frigateEventsSchema,
|
||||||
|
} from '../types';
|
||||||
|
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||||
import { homeAssistantWSRequest } from './ha';
|
import { homeAssistantWSRequest } from './ha';
|
||||||
|
|
||||||
export const FRIGATE_ICON_SVG_PATH =
|
export const FRIGATE_ICON_SVG_PATH =
|
||||||
@@ -35,7 +49,7 @@ const recordingSummarySchema = z
|
|||||||
.object({
|
.object({
|
||||||
day: z.preprocess((arg) => {
|
day: z.preprocess((arg) => {
|
||||||
// Must provide the hour:minute:second on parsing or Javascript will
|
// Must provide the hour:minute:second on parsing or Javascript will
|
||||||
// assume UTC midnight.
|
// assume *UTC* midnight.
|
||||||
return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg;
|
return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg;
|
||||||
}, z.date()),
|
}, z.date()),
|
||||||
events: z.number(),
|
events: z.number(),
|
||||||
@@ -74,9 +88,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 +116,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),
|
||||||
@@ -145,26 +159,151 @@ export async function retainEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FrigateGetEventsParameters {
|
||||||
|
instance_id?: string;
|
||||||
|
camera?: string;
|
||||||
|
label?: string;
|
||||||
|
zone?: string;
|
||||||
|
after?: number;
|
||||||
|
before?: number;
|
||||||
|
limit?: number;
|
||||||
|
has_clip?: boolean;
|
||||||
|
has_snapshot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an id that unique identifies a particular camera (not zone, object, etc)
|
* Get events over websocket. May throw.
|
||||||
* within a particular Frigate instance. ID will not (necessarily) be unique
|
* @param hass The Home Assistant object.
|
||||||
* within the card.
|
* @param params The events search parameters.
|
||||||
* @param cameraConfig The camera config.
|
* @returns An array of 'FrigateEvent's.
|
||||||
*/
|
*/
|
||||||
export const getUniqueFrigateCameraID = (config: CameraConfig): string => {
|
export const getEvents = async (
|
||||||
return [config.frigate.client_id, config.frigate.camera_name].join('/');
|
hass: HomeAssistant,
|
||||||
|
params?: FrigateGetEventsParameters,
|
||||||
|
): Promise<FrigateEvents> => {
|
||||||
|
return await homeAssistantWSRequest(
|
||||||
|
hass,
|
||||||
|
frigateEventsSchema,
|
||||||
|
{
|
||||||
|
type: 'frigate/events/get',
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an id that unique identifies a source of Frigate events. ID will not
|
* Get multiple sets of events.
|
||||||
* (necessarily) be unique within the card.
|
* @param hass The Home Assistant object.
|
||||||
* @param cameraConfig The camera config.
|
* @param params A Map of parameters keyed on any key.
|
||||||
|
* @returns A Map of key -> events.
|
||||||
*/
|
*/
|
||||||
export const getUniqueFrigateCameraEventsID = (config: CameraConfig): string => {
|
export const getEventsMultiple = async <T>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
params: Map<T, FrigateGetEventsParameters>,
|
||||||
|
): Promise<Map<T, FrigateEvents>> => {
|
||||||
|
const output: Map<T, FrigateEvents> = new Map();
|
||||||
|
const getEventsAndStore = async (
|
||||||
|
key: T,
|
||||||
|
param: FrigateGetEventsParameters,
|
||||||
|
): Promise<void> => {
|
||||||
|
output.set(key, await getEvents(hass, param));
|
||||||
|
};
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(params).map(([key, param]) => getEventsAndStore(key, param)),
|
||||||
|
);
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an event generate a title.
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
export const getEventTitle = (event: FrigateEvent): string => {
|
||||||
|
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const durationSeconds = Math.round(
|
||||||
|
event.end_time
|
||||||
|
? event.end_time - event.start_time
|
||||||
|
: Date.now() / 1000 - event.start_time,
|
||||||
|
);
|
||||||
|
return `${formatDateAndTime(
|
||||||
|
utcToZonedTime(event.start_time * 1000, localTimezone),
|
||||||
|
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
|
||||||
|
event.top_score * 100,
|
||||||
|
)}%]`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a thumbnail URL for an event.
|
||||||
|
* @param clientId The Frigate client id.
|
||||||
|
* @param event The event.
|
||||||
|
* @returns A string URL.
|
||||||
|
*/
|
||||||
|
export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => {
|
||||||
|
return `/api/frigate/${clientId}/thumbnail/${event.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a media content ID for an event.
|
||||||
|
* @param clientId The Frigate client id.
|
||||||
|
* @param cameraName The Frigate camera name.
|
||||||
|
* @param id The event id.
|
||||||
|
* @param mediaType The media type required.
|
||||||
|
* @returns A string media content id.
|
||||||
|
*/
|
||||||
|
export const getEventMediaContentID = (
|
||||||
|
clientId: string,
|
||||||
|
cameraName: string,
|
||||||
|
id: string,
|
||||||
|
mediaType: ClipsOrSnapshots,
|
||||||
|
): string => {
|
||||||
|
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a recording identifier.
|
||||||
|
* @param hass The HomeAssistant object.
|
||||||
|
* @param params The recording parameters to use in the identifer.
|
||||||
|
* @returns A recording identifier.
|
||||||
|
*/
|
||||||
|
export const getRecordingMediaContentID = (
|
||||||
|
params: BrowseRecordingQueryParameters,
|
||||||
|
): string => {
|
||||||
return [
|
return [
|
||||||
config.frigate.client_id,
|
'media-source://frigate',
|
||||||
config.frigate.camera_name,
|
params.clientId,
|
||||||
config.frigate.label,
|
'recordings',
|
||||||
config.frigate.zone,
|
`${params.year}-${String(params.month).padStart(2, '0')}`,
|
||||||
|
String(params.day).padStart(2, '0'),
|
||||||
|
String(params.hour).padStart(2, '0'),
|
||||||
|
params.cameraName,
|
||||||
].join('/');
|
].join('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to convert a timestamp to hours, minutes and seconds
|
||||||
|
* string. Heavily inspired by, and returning the same format as, the Frigate
|
||||||
|
* UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
||||||
|
* @param event The Frigate event.
|
||||||
|
* @returns A duration string.
|
||||||
|
*/
|
||||||
|
export function getEventDurationString(event: FrigateEvent): string {
|
||||||
|
if (!event.end_time) {
|
||||||
|
return localize('event.in_progress');
|
||||||
|
}
|
||||||
|
const start = fromUnixTime(event.start_time);
|
||||||
|
const end = fromUnixTime(event.end_time);
|
||||||
|
const hours = differenceInHours(end, start);
|
||||||
|
const minutes = differenceInMinutes(end, start) - hours * 60;
|
||||||
|
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
||||||
|
let duration = '';
|
||||||
|
|
||||||
|
if (hours) {
|
||||||
|
duration += `${hours}h `;
|
||||||
|
}
|
||||||
|
if (minutes) {
|
||||||
|
duration += `${minutes}m `;
|
||||||
|
}
|
||||||
|
duration += `${seconds}s`;
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import {
|
|
||||||
differenceInHours,
|
|
||||||
differenceInMinutes,
|
|
||||||
differenceInSeconds,
|
|
||||||
fromUnixTime,
|
|
||||||
} from 'date-fns';
|
|
||||||
import { homeAssistantWSRequest } from '.';
|
import { homeAssistantWSRequest } from '.';
|
||||||
import {
|
import {
|
||||||
dispatchErrorMessageEvent,
|
dispatchErrorMessageEvent,
|
||||||
@@ -14,7 +8,6 @@ import {
|
|||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import {
|
import {
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
BrowseRecordingQueryParameters,
|
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
frigateBrowseMediaSourceSchema,
|
frigateBrowseMediaSourceSchema,
|
||||||
@@ -27,7 +20,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 +71,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 +88,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 +111,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 +260,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(
|
||||||
@@ -425,19 +406,21 @@ export const createEventParentForChildren = (
|
|||||||
/**
|
/**
|
||||||
* Given a media video child with a given media_content_id.
|
* Given a media video child with a given media_content_id.
|
||||||
* @param title The title to use for the child.
|
* @param title The title to use for the child.
|
||||||
* @param media_con
|
* @param mediaContentID The media content id to use for the child.
|
||||||
* @param children The children media items.
|
* @param children The children media items.
|
||||||
* @returns A single parent containing the children.
|
* @returns A single parent containing the children.
|
||||||
*/
|
*/
|
||||||
export const createVideoChild = (
|
export const createChild = (
|
||||||
title: string,
|
title: string,
|
||||||
mediaContentID: string,
|
mediaContentID: string,
|
||||||
options?: {
|
options?: {
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
recording?: FrigateRecording;
|
recording?: FrigateRecording;
|
||||||
|
event?: FrigateEvent;
|
||||||
|
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,59 +428,19 @@ 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: {
|
|
||||||
recording: options.recording,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience function to convert a timestamp to hours, minutes and seconds
|
|
||||||
* string. Heavily inspired by, and returning the same format as, the Frigate
|
|
||||||
* UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
|
||||||
* @param event The Frigate event.
|
|
||||||
* @returns A duration string.
|
|
||||||
*/
|
|
||||||
export function getEventDurationString(event: FrigateEvent): string {
|
|
||||||
if (!event.end_time) {
|
|
||||||
return localize('event.in_progress');
|
|
||||||
}
|
}
|
||||||
const start = fromUnixTime(event.start_time);
|
if (options?.recording || options?.cameraID || options?.event) {
|
||||||
const end = fromUnixTime(event.end_time);
|
result.frigate = {}
|
||||||
const hours = differenceInHours(end, start);
|
if (options?.event) {
|
||||||
const minutes = differenceInMinutes(end, start) - hours * 60;
|
result.frigate.event = options.event;
|
||||||
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
|
||||||
let duration = '';
|
|
||||||
|
|
||||||
if (hours) {
|
|
||||||
duration += `${hours}h `;
|
|
||||||
}
|
}
|
||||||
if (minutes) {
|
if (options?.recording) {
|
||||||
duration += `${minutes}m `;
|
result.frigate.recording = options.recording;
|
||||||
}
|
}
|
||||||
duration += `${seconds}s`;
|
if (options?.cameraID) {
|
||||||
return duration;
|
result.frigate.cameraID = options.cameraID;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/**
|
return result;
|
||||||
* Generate a recording identifier.
|
|
||||||
* @param hass The HomeAssistant object.
|
|
||||||
* @param params The recording parameters to use in the identifer.
|
|
||||||
* @returns A recording identifier.
|
|
||||||
*/
|
|
||||||
export const generateRecordingIdentifier = (
|
|
||||||
params: BrowseRecordingQueryParameters,
|
|
||||||
): string => {
|
|
||||||
return [
|
|
||||||
'media-source://frigate',
|
|
||||||
params.clientId,
|
|
||||||
'recordings',
|
|
||||||
`${params.year}-${String(params.month).padStart(2, '0')}`,
|
|
||||||
String(params.day).padStart(2, '0'),
|
|
||||||
String(params.hour).padStart(2, '0'),
|
|
||||||
params.cameraName,
|
|
||||||
].join('/');
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ export const createFetchThumbnailTask = (
|
|||||||
host: ReactiveControllerHost,
|
host: ReactiveControllerHost,
|
||||||
getHASS: () => HomeAssistant | undefined,
|
getHASS: () => HomeAssistant | undefined,
|
||||||
getThumbnailURL: () => string | undefined,
|
getThumbnailURL: () => string | undefined,
|
||||||
|
autoRun = true,
|
||||||
): Task<FetchThumbnailTaskArgs, string | null> => {
|
): Task<FetchThumbnailTaskArgs, string | null> => {
|
||||||
return new Task(
|
return new Task(
|
||||||
host,
|
host,
|
||||||
async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
{
|
||||||
|
// Do not re-run the task if hass changes, unless it was previously undefined.
|
||||||
|
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
||||||
|
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
||||||
string | null
|
string | null
|
||||||
> => {
|
> => {
|
||||||
const hass = getHASS();
|
const hass = getHASS();
|
||||||
@@ -67,7 +71,7 @@ export const createFetchThumbnailTask = (
|
|||||||
}
|
}
|
||||||
return fetchThumbnail(hass, thumbnailURL);
|
return fetchThumbnail(hass, thumbnailURL);
|
||||||
},
|
},
|
||||||
// Do not re-run the task if hass changes, unless it was previously undefined.
|
autoRun: autoRun,
|
||||||
(): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
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 {
|
||||||
|
CameraConfig,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
FrigateCardError,
|
||||||
|
FrigateEvent,
|
||||||
|
FrigateEvents,
|
||||||
|
} from '../types.js';
|
||||||
|
import { errorToConsole, runWhenIdleIfSupported } from '../utils/basic.js';
|
||||||
|
import {
|
||||||
|
FrigateGetEventsParameters,
|
||||||
|
getEventsMultiple,
|
||||||
|
getRecordingSegments,
|
||||||
|
getRecordingsSummary,
|
||||||
|
RecordingSegments,
|
||||||
|
RecordingSummary,
|
||||||
|
} from './frigate.js';
|
||||||
|
import { dispatchFrigateCardErrorEvent } from '../components/message.js';
|
||||||
|
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
|
const RECORDING_SEGMENT_TOLERANCE = 60;
|
||||||
|
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
||||||
|
const TIMELINE_DATA_MANAGER_MAX_FETCH_COUNT = 10000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Garbage collect segments at most once an hour.
|
||||||
|
protected _throttledSegmentGarbageCollector = throttle(
|
||||||
|
() => {
|
||||||
|
runWhenIdleIfSupported(this._garbageCollectSegments.bind(this));
|
||||||
|
},
|
||||||
|
60 * 60 * 1000,
|
||||||
|
{ trailing: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(cameras: Map<string, CameraConfig>, mediaType: TimelineMediaType) {
|
||||||
|
this._cameras = cameras;
|
||||||
|
this._mediaType = mediaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dataview for a given set of camera.
|
||||||
|
* @param cameraIDs The cameraIDs to include.
|
||||||
|
* @param showRecordings Whether or not to show recordings.
|
||||||
|
* @returns A dataview.
|
||||||
|
*/
|
||||||
|
public createDataView(
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
showRecordings: boolean,
|
||||||
|
mediaType: TimelineMediaType,
|
||||||
|
): DataView<FrigateCardTimelineItem> {
|
||||||
|
return new DataView(this._dataset, {
|
||||||
|
filter: (item: FrigateCardTimelineItem) =>
|
||||||
|
// Only return items for the given cameras.
|
||||||
|
!!item.group &&
|
||||||
|
cameraIDs.has(String(item.group)) &&
|
||||||
|
// Don't return recordings if the user does not want them.
|
||||||
|
(showRecordings || item.type !== 'background') &&
|
||||||
|
// Don't return events that are the wrong media type.
|
||||||
|
(item.type === 'background' ||
|
||||||
|
mediaType === 'all' ||
|
||||||
|
(mediaType === 'clips' && !!item.event?.has_clip) ||
|
||||||
|
(mediaType === 'snapshots' && !!item.event?.has_snapshot)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dataview for segments.
|
||||||
|
* @returns A dataview.
|
||||||
|
*/
|
||||||
|
public createSegmentDataView(): DataView<RecordingSegmentsItem> {
|
||||||
|
return new DataView(this._recordingSegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying recording segments dataset.
|
||||||
|
*/
|
||||||
|
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 events for the given camera.
|
||||||
|
* @param cameraID The camera ID.
|
||||||
|
* @param events The array of events.
|
||||||
|
*/
|
||||||
|
protected _addEvents(cameraID: string, events: FrigateEvents): void {
|
||||||
|
this._dataset.update(
|
||||||
|
events.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
group: cameraID,
|
||||||
|
content: '',
|
||||||
|
event: event,
|
||||||
|
start: event.start_time * 1000,
|
||||||
|
type: event.end_time ? 'range' : 'point',
|
||||||
|
...(event.end_time && { end: event.end_time * 1000 }),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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._fetchRecordingSummary(hass),
|
||||||
|
...(segmentEnd > segmentStart
|
||||||
|
? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this._throttledSegmentGarbageCollector();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garbage collect recording segments that no longer feature in the summary.
|
||||||
|
*/
|
||||||
|
protected _garbageCollectSegments(): void {
|
||||||
|
if (!this._recordingSegments || !this._recordingSummary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
|
||||||
|
// items) and each item must be examined, so care required here to stick to
|
||||||
|
// nothing worse than O(n) performance.
|
||||||
|
const getHourID = (cameraID: string, day: number, hour: number): string => {
|
||||||
|
return `${cameraID}/${day}/${hour}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goodHours: Set<string> = new Set();
|
||||||
|
for (const cameraID of this._recordingSummary.keys()) {
|
||||||
|
for (const summaryDay of this._recordingSummary?.get(cameraID) ?? []) {
|
||||||
|
for (const summaryHour of summaryDay.hours) {
|
||||||
|
goodHours.add(getHourID(cameraID, summaryDay.day.getDate(), summaryHour.hour));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteIDs: string[] = [];
|
||||||
|
this._recordingSegments.forEach((item, id) => {
|
||||||
|
const startDate = fromUnixTime(item.start / 1000);
|
||||||
|
const hourID = getHourID(item.cameraID, startDate.getDate(), startDate.getHours());
|
||||||
|
|
||||||
|
// ~O(1) lookup time for a JS set.
|
||||||
|
if (!goodHours.has(hourID)) {
|
||||||
|
deleteIDs.push(String(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._recordingSegments.remove(deleteIDs);
|
||||||
|
this._compressRecordingSegmentsOntoTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: Map<string, FrigateGetEventsParameters> = new Map();
|
||||||
|
|
||||||
|
this._cameras.forEach((cameraConfig, cameraID) => {
|
||||||
|
if (
|
||||||
|
cameraConfig.frigate.camera_name &&
|
||||||
|
cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE
|
||||||
|
) {
|
||||||
|
params.set(cameraID, {
|
||||||
|
instance_id: cameraConfig.frigate.client_id,
|
||||||
|
camera: cameraConfig.frigate.camera_name,
|
||||||
|
...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }),
|
||||||
|
...(cameraConfig.frigate.zone && { label: cameraConfig.frigate.zone }),
|
||||||
|
before: Math.floor(end.getTime() / 1000),
|
||||||
|
after: Math.floor(start.getTime() / 1000),
|
||||||
|
limit: TIMELINE_DATA_MANAGER_MAX_FETCH_COUNT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let results: Map<string, FrigateEvents>;
|
||||||
|
try {
|
||||||
|
results = await getEventsMultiple(hass, params);
|
||||||
|
} catch (e) {
|
||||||
|
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||||
|
}
|
||||||
|
results.forEach((params, cameraID) => this._addEvents(cameraID, params));
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1060,6 +1060,11 @@ custom-card-helpers@^1.9.0:
|
|||||||
superstruct "^0.15.3"
|
superstruct "^0.15.3"
|
||||||
typescript "^4.5.4"
|
typescript "^4.5.4"
|
||||||
|
|
||||||
|
date-fns-tz@^1.3.7:
|
||||||
|
version "1.3.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-1.3.7.tgz#e8e9d2aaceba5f1cc0e677631563081fdcb0e69a"
|
||||||
|
integrity sha512-1t1b8zyJo+UI8aR+g3iqr5fkUHWpd58VBx8J/ZSQ+w7YrGlw80Ag4sA86qkfCXRBLmMc4I2US+aPMd4uKvwj5g==
|
||||||
|
|
||||||
date-fns@^2.29.2:
|
date-fns@^2.29.2:
|
||||||
version "2.29.2"
|
version "2.29.2"
|
||||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931"
|
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931"
|
||||||
|
|||||||
Reference in New Issue
Block a user