Initial draft of recordings support.
This commit is contained in:
@@ -483,6 +483,7 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin
|
|||||||
| `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. |
|
| `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.|
|
| `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.|
|
| `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).|
|
||||||
| `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.|
|
| `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.|
|
||||||
| `actions` | | :heavy_multiplication_x: | Actions to use for the `timeline` views. See [actions](#actions) below.|
|
| `actions` | | :heavy_multiplication_x: | Actions to use for the `timeline` views. See [actions](#actions) below.|
|
||||||
|
|
||||||
@@ -1617,6 +1618,7 @@ Reference: [Timeline Options](#timeline-options).
|
|||||||
timeline:
|
timeline:
|
||||||
clustering_threshold: 3
|
clustering_threshold: 3
|
||||||
media: all
|
media: all
|
||||||
|
show_recordings: true
|
||||||
window_seconds: 3600
|
window_seconds: 3600
|
||||||
controls:
|
controls:
|
||||||
thumbnails:
|
thumbnails:
|
||||||
|
|||||||
+2
-2
@@ -975,7 +975,7 @@ export class FrigateCard extends LitElement {
|
|||||||
* @returns A boolean indicating whether the camera was changed.
|
* @returns A boolean indicating whether the camera was changed.
|
||||||
*/
|
*/
|
||||||
protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean {
|
protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean {
|
||||||
if (!this._view) {
|
if (!this._view || !this._isAutomatedViewUpdateAllowed(true)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,7 +1001,7 @@ export class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) {
|
if (triggerChanges) {
|
||||||
if (!this._triggers.size) {
|
if (!this._triggers.size) {
|
||||||
this._changeView();
|
this._changeView();
|
||||||
changedCamera = true;
|
changedCamera = true;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio
|
|||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import elementsStyle from '../scss/elements.scss';
|
import elementsStyle from '../scss/elements.scss';
|
||||||
import {
|
import {
|
||||||
|
FrigateCardError,
|
||||||
FrigateConditional,
|
FrigateConditional,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
PictureElements
|
PictureElements
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
import { dispatchErrorMessageEvent } from './message.js';
|
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||||
|
|
||||||
/* A note on picture element rendering:
|
/* A note on picture element rendering:
|
||||||
*
|
*
|
||||||
@@ -108,7 +109,7 @@ export class FrigateCardElementsCore extends LitElement {
|
|||||||
element.setConfig(config);
|
element.setConfig(config);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e, (e as Error).stack);
|
console.error(e, (e as Error).stack);
|
||||||
throw new Error(localize('error.invalid_elements_config'));
|
throw new FrigateCardError(localize('error.invalid_elements_config'));
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -125,7 +126,7 @@ export class FrigateCardElementsCore extends LitElement {
|
|||||||
this._root = this._createRoot();
|
this._root = this._createRoot();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return dispatchErrorMessageEvent(this, (e as Error).message);
|
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -612,6 +612,13 @@ export class FrigateCardLiveProvider extends LitElement {
|
|||||||
this._providerRef.value?.unmute();
|
this._providerRef.value?.unmute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video.
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
this._providerRef.value?.seek(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
protected _getResolvedProvider(): LiveProvider {
|
protected _getResolvedProvider(): LiveProvider {
|
||||||
if (this.cameraConfig?.live_provider === 'auto') {
|
if (this.cameraConfig?.live_provider === 'auto') {
|
||||||
if (
|
if (
|
||||||
@@ -711,6 +718,13 @@ export class FrigateCardLiveFrigate extends LitElement {
|
|||||||
this._playerRef.value?.unmute();
|
this._playerRef.value?.unmute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video.
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
this._playerRef.value?.seek(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
@@ -810,6 +824,16 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video.
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
const player = this._getPlayer();
|
||||||
|
if (player) {
|
||||||
|
player.currentTime = seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the underlying video player.
|
* Get the underlying video player.
|
||||||
* @returns The player or `null` if not found.
|
* @returns The player or `null` if not found.
|
||||||
@@ -867,8 +891,9 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
|
|||||||
return dispatchErrorMessageEvent(
|
return dispatchErrorMessageEvent(
|
||||||
this,
|
this,
|
||||||
e instanceof FrigateCardError
|
e instanceof FrigateCardError
|
||||||
? (e as FrigateCardError).message
|
? e.message
|
||||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||||
|
(e as FrigateCardError).context
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return html`${webrtcElement}`;
|
return html`${webrtcElement}`;
|
||||||
@@ -964,6 +989,14 @@ export class FrigateCardLiveJSMPEG extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video (unsupported).
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public seek(_seconds: number): void {
|
||||||
|
// JSMPEG does not support seeking.
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a signed player URL.
|
* Get a signed player URL.
|
||||||
* @returns A URL or null.
|
* @returns A URL or null.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import messageStyle from '../scss/message.scss';
|
import messageStyle from '../scss/message.scss';
|
||||||
import { Message } from '../types.js';
|
import { FrigateCardError, Message } from '../types.js';
|
||||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
|
|
||||||
@customElement('frigate-card-message')
|
@customElement('frigate-card-message')
|
||||||
@@ -140,3 +140,19 @@ export function dispatchErrorMessageEvent(
|
|||||||
context: context,
|
context: context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an event with an error message to show to the user.
|
||||||
|
* @param element The element to send the event.
|
||||||
|
* @param message The message to show.
|
||||||
|
*/
|
||||||
|
export function dispatchFrigateCardErrorEvent(
|
||||||
|
element: HTMLElement,
|
||||||
|
error: FrigateCardError
|
||||||
|
): void {
|
||||||
|
dispatchFrigateCardEvent<Message>(element, 'message', {
|
||||||
|
message: error.message,
|
||||||
|
type: 'error',
|
||||||
|
context: error.context || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import surroundThumbnailsStyle from '../scss/surround.scss';
|
|||||||
import {
|
import {
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
|
FrigateCardError,
|
||||||
FrigateCardView,
|
FrigateCardView,
|
||||||
ThumbnailsControlConfig
|
ThumbnailsControlConfig
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
multipleBrowseMediaQueryMerged
|
multipleBrowseMediaQueryMerged
|
||||||
} from '../utils/ha/browse-media';
|
} from '../utils/ha/browse-media';
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
import { dispatchErrorMessageEvent } from './message.js';
|
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
try {
|
try {
|
||||||
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
|
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return dispatchErrorMessageEvent(this, (e as Error).message);
|
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
||||||
}
|
}
|
||||||
if (getFirstTrueMediaChildIndex(parent) !== null) {
|
if (getFirstTrueMediaChildIndex(parent) !== null) {
|
||||||
this.view
|
this.view
|
||||||
@@ -149,6 +150,7 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
view: this.targetView || 'event',
|
view: this.targetView || 'event',
|
||||||
target: ev.detail.target,
|
target: ev.detail.target,
|
||||||
childIndex: ev.detail.childIndex,
|
childIndex: ev.detail.childIndex,
|
||||||
|
context: null,
|
||||||
})
|
})
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
}}
|
}}
|
||||||
|
|||||||
+110
-14
@@ -3,8 +3,14 @@ import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.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 thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
||||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||||
import type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js';
|
import type {
|
||||||
|
FrigateBrowseMediaSource,
|
||||||
|
FrigateEvent,
|
||||||
|
FrigateRecording,
|
||||||
|
} from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import { prettifyTitle } from '../utils/basic.js';
|
import { prettifyTitle } from '../utils/basic.js';
|
||||||
import { getEventDurationString } from '../utils/ha/browse-media.js';
|
import { getEventDurationString } from '../utils/ha/browse-media.js';
|
||||||
@@ -13,8 +19,49 @@ 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;
|
||||||
|
|
||||||
@customElement('frigate-card-thumbnail-details')
|
@customElement('frigate-card-thumbnail-feature-event')
|
||||||
export class FrigateCardThumbnailDetails extends LitElement {
|
export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public thumbnail?: string;
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html`
|
||||||
|
${this.thumbnail
|
||||||
|
? html`<img src="${this.thumbnail}" />`
|
||||||
|
: html`<ha-icon
|
||||||
|
icon="mdi:image-off"
|
||||||
|
title=${localize('thumbnail.no_thumbnail')}
|
||||||
|
></ha-icon> `}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResult {
|
||||||
|
return unsafeCSS(thumbnailFeatureEventStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-thumbnail-feature-recording')
|
||||||
|
export class FrigateCardThumbnailFeatureRecording extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public date?: Date;
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (!this.date) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div class="title">${format(this.date, 'HH:mm')}</div>
|
||||||
|
<div class="subtitle">${format(this.date, 'MMM do')}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResult {
|
||||||
|
return unsafeCSS(thumbnailFeatureRecordingStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-thumbnail-details-event')
|
||||||
|
export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public event?: FrigateEvent;
|
public event?: FrigateEvent;
|
||||||
|
|
||||||
@@ -39,9 +86,29 @@ export class FrigateCardThumbnailDetails extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static get styles(): CSSResult {
|
||||||
* Get element styles.
|
return unsafeCSS(thumbnailDetailsStyle);
|
||||||
*/
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-thumbnail-details-recording')
|
||||||
|
export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public recording?: FrigateRecording;
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (!this.recording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return html`<div class="left">
|
||||||
|
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<span class="larger">${this.recording.events}</span>
|
||||||
|
<span>${localize('recording.events')}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
static get styles(): CSSResult {
|
||||||
return unsafeCSS(thumbnailDetailsStyle);
|
return unsafeCSS(thumbnailDetailsStyle);
|
||||||
}
|
}
|
||||||
@@ -90,6 +157,7 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
let event: FrigateEvent | null = null;
|
let event: FrigateEvent | null = null;
|
||||||
|
let recording: FrigateRecording | null = null;
|
||||||
let thumbnail: string | null = null;
|
let thumbnail: string | null = null;
|
||||||
let label: string | null = null;
|
let label: string | null = null;
|
||||||
|
|
||||||
@@ -97,6 +165,7 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
if (this.target && this.target.children && this.childIndex !== undefined) {
|
if (this.target && this.target.children && this.childIndex !== undefined) {
|
||||||
const media = this.target.children[this.childIndex];
|
const media = this.target.children[this.childIndex];
|
||||||
event = media.frigate?.event ?? null;
|
event = media.frigate?.event ?? null;
|
||||||
|
recording = media.frigate?.recording ?? null;
|
||||||
thumbnail = media.thumbnail;
|
thumbnail = media.thumbnail;
|
||||||
label = media.title;
|
label = media.title;
|
||||||
}
|
}
|
||||||
@@ -108,26 +177,37 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
|
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
|
||||||
label = this.label ? this.label : label;
|
label = this.label ? this.label : label;
|
||||||
|
|
||||||
if (!thumbnail) {
|
if (!event && !recording) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html` <img
|
return html` ${event
|
||||||
|
? html`<frigate-card-thumbnail-feature-event
|
||||||
aria-label="${label ?? ''}"
|
aria-label="${label ?? ''}"
|
||||||
src="${thumbnail}"
|
|
||||||
title="${label ?? ''}"
|
title="${label ?? ''}"
|
||||||
/>
|
.thumbnail=${thumbnail ?? undefined}
|
||||||
|
.label=${label ?? undefined}
|
||||||
|
></frigate-card-thumbnail-feature-event>`
|
||||||
|
: html`<frigate-card-thumbnail-feature-recording
|
||||||
|
aria-label="${label ?? ''}"
|
||||||
|
title="${label ?? ''}"
|
||||||
|
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
|
||||||
|
></frigate-card-thumbnail-feature-recording>`}
|
||||||
${this.controls && event?.retain_indefinitely
|
${this.controls && event?.retain_indefinitely
|
||||||
? html` <ha-icon
|
? html` <ha-icon
|
||||||
class="favorite"
|
class="favorite"
|
||||||
icon="mdi:star"
|
icon="mdi:star"
|
||||||
title=${localize('thumbnail.retain_indefinitely')}
|
title=${localize('thumbnail.retain_indefinitely')}
|
||||||
/>`
|
/></ha-icon>`
|
||||||
: ``}
|
: ``}
|
||||||
${this.details && event
|
${this.details && event
|
||||||
? html`<frigate-card-thumbnail-details
|
? html`<frigate-card-thumbnail-details-event
|
||||||
.event=${event}
|
.event=${event ?? undefined}
|
||||||
></frigate-card-thumbnail-details>`
|
></frigate-card-thumbnail-details-event>`
|
||||||
|
: this.details && recording
|
||||||
|
? html`<frigate-card-thumbnail-details-recording
|
||||||
|
.recording=${recording ?? undefined}
|
||||||
|
></frigate-card-thumbnail-details-recording>`
|
||||||
: html``}
|
: html``}
|
||||||
${this.controls
|
${this.controls
|
||||||
? html`<ha-icon
|
? html`<ha-icon
|
||||||
@@ -136,6 +216,7 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
title=${localize('thumbnail.timeline')}
|
title=${localize('thumbnail.timeline')}
|
||||||
@click=${(ev: Event) => {
|
@click=${(ev: Event) => {
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
|
if (event) {
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
view: 'timeline',
|
view: 'timeline',
|
||||||
@@ -144,6 +225,21 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
context: {},
|
context: {},
|
||||||
})
|
})
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
|
} else if (recording) {
|
||||||
|
this.view
|
||||||
|
?.evolve({
|
||||||
|
view: 'timeline',
|
||||||
|
target: null,
|
||||||
|
childIndex: null,
|
||||||
|
context: {
|
||||||
|
window: {
|
||||||
|
start: fromUnixTime(recording.start_time),
|
||||||
|
end: fromUnixTime(recording.end_time),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
></ha-icon>`
|
></ha-icon>`
|
||||||
: ''}`;
|
: ''}`;
|
||||||
|
|||||||
+365
-71
@@ -1,5 +1,15 @@
|
|||||||
|
// TODO: In viewer, the seek is being applied to the 2nd media. Change away from play_time?
|
||||||
|
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { add, fromUnixTime, sub } from 'date-fns';
|
import {
|
||||||
|
add,
|
||||||
|
endOfHour,
|
||||||
|
format,
|
||||||
|
fromUnixTime,
|
||||||
|
getUnixTime,
|
||||||
|
startOfHour,
|
||||||
|
sub
|
||||||
|
} from 'date-fns';
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
@@ -29,32 +39,44 @@ import timelineStyle from '../scss/timeline.scss';
|
|||||||
import {
|
import {
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
frigateCardConfigDefaults,
|
frigateCardConfigDefaults,
|
||||||
|
FrigateCardError,
|
||||||
FrigateEvent,
|
FrigateEvent,
|
||||||
TimelineConfig
|
TimelineConfig
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { dispatchFrigateCardEvent, prettifyTitle } from '../utils/basic';
|
||||||
import { getCameraTitle } from '../utils/camera.js';
|
import { getCameraTitle } from '../utils/camera.js';
|
||||||
|
import {
|
||||||
|
getRecordingSegments,
|
||||||
|
getRecordingsSummary,
|
||||||
|
RecordingSegments,
|
||||||
|
RecordingSummary
|
||||||
|
} from '../utils/frigate';
|
||||||
import {
|
import {
|
||||||
createEventParentForChildren,
|
createEventParentForChildren,
|
||||||
|
createVideoChild,
|
||||||
|
generateRecordingIdentifier,
|
||||||
getBrowseMediaQueryParameters,
|
getBrowseMediaQueryParameters,
|
||||||
isTrueMedia,
|
isTrueMedia,
|
||||||
multipleBrowseMediaQuery
|
multipleBrowseMediaQuery
|
||||||
} from '../utils/ha/browse-media';
|
} from '../utils/ha/browse-media';
|
||||||
import { View, ViewContext } from '../view';
|
import { View, ViewContext } from '../view';
|
||||||
import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js';
|
import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js';
|
||||||
import './surround-thumbnails.js';
|
import './surround-thumbnails.js';
|
||||||
|
|
||||||
const TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS = 10;
|
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
||||||
|
|
||||||
interface FrigateCardGroupData {
|
interface FrigateCardGroupData {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
interface FrigateCardTimelineItem extends TimelineItem {
|
interface FrigateCardTimelineItem extends TimelineItem {
|
||||||
event: FrigateEvent;
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
event?: FrigateEvent;
|
||||||
source?: FrigateBrowseMediaSource;
|
source?: FrigateBrowseMediaSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +90,17 @@ interface TimelineViewContext extends ViewContext {
|
|||||||
|
|
||||||
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
|
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
|
||||||
|
|
||||||
|
interface CameraRecordings {
|
||||||
|
segments: RecordingSegments;
|
||||||
|
summary: RecordingSummary;
|
||||||
|
}
|
||||||
|
|
||||||
const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)');
|
const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A manager to maintain/fetch timeline events.
|
* A manager to maintain/fetch timeline events.
|
||||||
*/
|
*/
|
||||||
class TimelineEventManager {
|
class TimelineDataManager {
|
||||||
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
||||||
|
|
||||||
// The earliest date managed.
|
// The earliest date managed.
|
||||||
@@ -87,7 +114,7 @@ class TimelineEventManager {
|
|||||||
|
|
||||||
// The maximum allowable age of fetch data (will not fetch more frequently
|
// The maximum allowable age of fetch data (will not fetch more frequently
|
||||||
// than this).
|
// than this).
|
||||||
protected _maxAgeSeconds: number = TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS;
|
protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
|
||||||
|
|
||||||
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
|
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
|
||||||
protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
|
protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
|
||||||
@@ -183,9 +210,7 @@ class TimelineEventManager {
|
|||||||
* @param end An optional end of the date range.
|
* @param end An optional end of the date range.
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public hasCoverage(start: Date, end?: Date): boolean {
|
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
|
||||||
const now = new Date().getTime();
|
|
||||||
|
|
||||||
// Never fetched: no coverage.
|
// Never fetched: no coverage.
|
||||||
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
|
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
|
||||||
return false;
|
return false;
|
||||||
@@ -194,7 +219,7 @@ class TimelineEventManager {
|
|||||||
// If the most recent fetch is older than maxAgeSeconds: no coverage.
|
// If the most recent fetch is older than maxAgeSeconds: no coverage.
|
||||||
if (
|
if (
|
||||||
this._maxAgeSeconds &&
|
this._maxAgeSeconds &&
|
||||||
now - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -219,7 +244,7 @@ class TimelineEventManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
|
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
|
||||||
if (now - end.getTime() > this._maxAgeSeconds * 1000) {
|
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,21 +262,108 @@ class TimelineEventManager {
|
|||||||
* @param end Fetch events that start earlier than this date.
|
* @param end Fetch events that start earlier than this date.
|
||||||
* @returns `true` if events were fetched, `false` otherwise.
|
* @returns `true` if events were fetched, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
public async fetchEventsIfNecessary(
|
public async fetchIfNecessary(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
hass: HomeAssistant,
|
hass: ExtendedHomeAssistant,
|
||||||
cameras: Map<string, CameraConfig>,
|
cameras: Map<string, CameraConfig>,
|
||||||
media: TimelineMediaType,
|
eventMedia: TimelineMediaType,
|
||||||
start: Date,
|
start: Date,
|
||||||
end: Date,
|
end: Date,
|
||||||
|
recordings?: boolean,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (this.hasCoverage(start, end)) {
|
const now = new Date();
|
||||||
|
if (this.hasCoverage(now, start, end)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await this._fetchEvents(element, hass, cameras, media, start, end);
|
|
||||||
|
// Cannot fetch the future.
|
||||||
|
end = end > now ? now : end;
|
||||||
|
|
||||||
|
if (!this._dateStart || start < this._dateStart) {
|
||||||
|
this._dateStart = start;
|
||||||
|
}
|
||||||
|
if (!this._dateEnd || end > this._dateEnd) {
|
||||||
|
this._dateEnd = end;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
cameras,
|
||||||
|
eventMedia,
|
||||||
|
this._dateStart,
|
||||||
|
this._dateEnd,
|
||||||
|
),
|
||||||
|
...(recordings ? [this._fetchRecordings(element, hass, cameras)] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recording hours for the timeline.
|
||||||
|
* @param element The element to send error events from.
|
||||||
|
* @param hass The HomeAssistant object.
|
||||||
|
* @param cameras The cameras map.
|
||||||
|
* @param start Fetch events that start later than this date.
|
||||||
|
* @param end Fetch events that start earlier than this date.
|
||||||
|
*/
|
||||||
|
protected async _fetchRecordings(
|
||||||
|
element: HTMLElement,
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
): Promise<void> {
|
||||||
|
const items: FrigateCardTimelineItem[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const storeRecordings = async (
|
||||||
|
camera: string,
|
||||||
|
config: CameraConfig,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!config.camera_name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let summary: RecordingSummary;
|
||||||
|
try {
|
||||||
|
summary = await getRecordingsSummary(hass, config.client_id, config.camera_name);
|
||||||
|
} catch (e) {
|
||||||
|
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dayData of summary) {
|
||||||
|
for (const hourData of dayData.hours) {
|
||||||
|
const hour = add(dayData.day, { hours: hourData.hour });
|
||||||
|
const endHour = endOfHour(hour);
|
||||||
|
items.push({
|
||||||
|
id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`,
|
||||||
|
group: camera,
|
||||||
|
start: getUnixTime(startOfHour(hour)) * 1000,
|
||||||
|
|
||||||
|
// Don't let the recordings show off into the future (even though it
|
||||||
|
// is intended to be indicative of any recordings within that hour
|
||||||
|
// -- it still looks strange!)
|
||||||
|
end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000,
|
||||||
|
type: 'background',
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) =>
|
||||||
|
storeRecordings(camera, config),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this._dataset.update(items);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch events for the timeline.
|
* Fetch events for the timeline.
|
||||||
* @param element The element to send error events from.
|
* @param element The element to send error events from.
|
||||||
@@ -265,34 +377,16 @@ class TimelineEventManager {
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameras: Map<string, CameraConfig>,
|
cameras: Map<string, CameraConfig>,
|
||||||
media: TimelineMediaType,
|
media: TimelineMediaType,
|
||||||
start?: Date,
|
start: Date,
|
||||||
end?: Date,
|
end: Date,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this._dateStart || (start && start < this._dateStart)) {
|
|
||||||
this._dateStart = start;
|
|
||||||
}
|
|
||||||
if (!this._dateEnd || (end && end > this._dateEnd)) {
|
|
||||||
this._dateEnd = end;
|
|
||||||
}
|
|
||||||
if (!this._dateStart || !this._dateEnd) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._dateFetch = new Date();
|
|
||||||
|
|
||||||
const params: BrowseMediaQueryParameters[] = [];
|
const params: BrowseMediaQueryParameters[] = [];
|
||||||
cameras.forEach((cameraConfig, cameraID) => {
|
cameras.forEach((cameraConfig, cameraID) => {
|
||||||
(media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
|
(media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
|
||||||
if (
|
if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) {
|
||||||
this._dateEnd &&
|
|
||||||
this._dateStart &&
|
|
||||||
cameraConfig.camera_name !== CAMERA_BIRDSEYE
|
|
||||||
) {
|
|
||||||
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
|
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
|
||||||
// Events are always fetched for the maximum extent of the managed
|
before: end.getTime() / 1000,
|
||||||
// range. This is because events may change at any point in time
|
after: start.getTime() / 1000,
|
||||||
// (e.g. a long-running event that ends).
|
|
||||||
before: this._dateEnd.getTime() / 1000,
|
|
||||||
after: this._dateStart.getTime() / 1000,
|
|
||||||
unlimited: true,
|
unlimited: true,
|
||||||
mediaType: mediaType as 'clips' | 'snapshots',
|
mediaType: mediaType as 'clips' | 'snapshots',
|
||||||
});
|
});
|
||||||
@@ -311,7 +405,7 @@ class TimelineEventManager {
|
|||||||
try {
|
try {
|
||||||
results = await multipleBrowseMediaQuery(hass, params);
|
results = await multipleBrowseMediaQuery(hass, params);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return dispatchErrorMessageEvent(element, (e as Error).message);
|
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [query, result] of results.entries()) {
|
for (const [query, result] of results.entries()) {
|
||||||
@@ -325,7 +419,7 @@ class TimelineEventManager {
|
|||||||
@customElement('frigate-card-timeline')
|
@customElement('frigate-card-timeline')
|
||||||
export class FrigateCardTimeline extends LitElement {
|
export class FrigateCardTimeline extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected hass?: HomeAssistant;
|
protected hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected view?: Readonly<View>;
|
protected view?: Readonly<View>;
|
||||||
@@ -371,7 +465,7 @@ export class FrigateCardTimeline extends LitElement {
|
|||||||
@customElement('frigate-card-timeline-core')
|
@customElement('frigate-card-timeline-core')
|
||||||
export class FrigateCardTimelineCore extends LitElement {
|
export class FrigateCardTimelineCore extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected hass?: HomeAssistant;
|
protected hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected view?: Readonly<View>;
|
protected view?: Readonly<View>;
|
||||||
@@ -382,12 +476,17 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected timelineConfig?: TimelineConfig;
|
protected timelineConfig?: TimelineConfig;
|
||||||
|
|
||||||
protected _events = new TimelineEventManager({
|
protected _data = new TimelineDataManager({
|
||||||
tooltipCallback: this._getTooltip.bind(this),
|
tooltipCallback: this._getTooltip.bind(this),
|
||||||
});
|
});
|
||||||
protected _refTimeline: Ref<HTMLElement> = createRef();
|
protected _refTimeline: Ref<HTMLElement> = createRef();
|
||||||
protected _timeline?: Timeline;
|
protected _timeline?: Timeline;
|
||||||
|
|
||||||
|
// Need a way to separate when a user clicks (to pan the timeline) vs when a
|
||||||
|
// user clicks (to choose a recording (non-event) to play). On pan,
|
||||||
|
// _wasDragged will be set to true, and the click subsequently ignored.
|
||||||
|
protected _wasDragged = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a tooltip for a given timeline event.
|
* Get a tooltip for a given timeline event.
|
||||||
* @param source The FrigateBrowseMediaSource in question.
|
* @param source The FrigateBrowseMediaSource in question.
|
||||||
@@ -446,12 +545,206 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
></div>`;
|
></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of seconds to seek into a video stream consisting of the
|
||||||
|
* provided segments to reach the target time provided.
|
||||||
|
* @param time Target time.
|
||||||
|
* @param segments A RecordingSegments object.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
protected _getSeekTime(time: Date, segments: RecordingSegments): number | null {
|
||||||
|
if (!segments.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const target = getUnixTime(time);
|
||||||
|
const hourStart = getUnixTime(startOfHour(time));
|
||||||
|
let seekSeconds = 0;
|
||||||
|
|
||||||
|
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.start_time > target) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const start = segment.start_time < hourStart ? hourStart : segment.start_time;
|
||||||
|
const end = segment.end_time > target ? target : segment.end_time;
|
||||||
|
seekSeconds += end - start;
|
||||||
|
}
|
||||||
|
return seekSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create recording objects.
|
||||||
|
* @param results A map of camera ID to a CameraRecordings object.
|
||||||
|
* @param time The target time for the recordings.
|
||||||
|
* @param onlyMatchingHour If `true` only shows the hour matching the target
|
||||||
|
* for the provided cameras, otherwise shows all hours.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
protected _createRecordingChildren(
|
||||||
|
results: Map<string, CameraRecordings>,
|
||||||
|
time: Date,
|
||||||
|
onlyMatchingHour: boolean,
|
||||||
|
): FrigateBrowseMediaSource[] {
|
||||||
|
const children: FrigateBrowseMediaSource[] = [];
|
||||||
|
const processedCameras: Set<string> = new Set();
|
||||||
|
|
||||||
|
for (const [camera, recording] of results.entries()) {
|
||||||
|
const config = this.cameras?.get(camera);
|
||||||
|
if (!config?.camera_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is a single set of recordings for a given Frigate camera name.
|
||||||
|
// Zones on that same camera do not get separate recordings. The card may
|
||||||
|
// have multiple instances of the same camera for different zoness, so
|
||||||
|
// need to enforce uniqueness here.
|
||||||
|
const uniqueID = `${config.client_id}/${config.camera_name}`;
|
||||||
|
if (processedCameras.has(uniqueID)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processedCameras.add(uniqueID);
|
||||||
|
|
||||||
|
const seekSeconds = this._getSeekTime(time, recording.segments);
|
||||||
|
if (seekSeconds === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dayData of recording.summary) {
|
||||||
|
for (const hourData of dayData.hours) {
|
||||||
|
const hour = add(dayData.day, { hours: hourData.hour });
|
||||||
|
const startHour = startOfHour(hour);
|
||||||
|
const endHour = endOfHour(hour);
|
||||||
|
const isMatchingHour = time >= startHour && time <= endHour;
|
||||||
|
|
||||||
|
if (!onlyMatchingHour || isMatchingHour) {
|
||||||
|
children.push(
|
||||||
|
createVideoChild(
|
||||||
|
`${prettifyTitle(config.camera_name)} ${format(
|
||||||
|
hour,
|
||||||
|
'yyyy-MM-dd HH:mm',
|
||||||
|
)}`,
|
||||||
|
generateRecordingIdentifier({
|
||||||
|
clientId: config.client_id,
|
||||||
|
year: dayData.day.getFullYear(),
|
||||||
|
month: dayData.day.getMonth() + 1,
|
||||||
|
day: dayData.day.getDate(),
|
||||||
|
hour: hourData.hour,
|
||||||
|
cameraName: config.camera_name,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
recording: {
|
||||||
|
camera: config.camera_name,
|
||||||
|
start_time: getUnixTime(startHour),
|
||||||
|
end_time: getUnixTime(endHour),
|
||||||
|
events: hourData.events,
|
||||||
|
...(isMatchingHour && { play_time: seekSeconds }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the view to a recording.
|
||||||
|
* @param time The time of the recording to show.
|
||||||
|
* @param camera An optional camera to show a recording of, otherwise all
|
||||||
|
* cameras are shown at the given time.
|
||||||
|
*/
|
||||||
|
protected async _changeViewToRecording(time: Date, camera?: string): Promise<void> {
|
||||||
|
if (!this.hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = endOfHour(time);
|
||||||
|
const after = startOfHour(time);
|
||||||
|
const results: Map<string, CameraRecordings> = new Map();
|
||||||
|
|
||||||
|
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
|
||||||
|
if (!config || !config.camera_name || !this.hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cameraResults = await Promise.all([
|
||||||
|
getRecordingSegments(
|
||||||
|
this.hass,
|
||||||
|
config.client_id,
|
||||||
|
config.camera_name,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
),
|
||||||
|
getRecordingsSummary(this.hass, config.client_id, config.camera_name),
|
||||||
|
]);
|
||||||
|
results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] });
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])];
|
||||||
|
await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera))));
|
||||||
|
|
||||||
|
const children = this._createRecordingChildren(results, time, !camera);
|
||||||
|
if (!children.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let childIndex = 0;
|
||||||
|
if (camera) {
|
||||||
|
childIndex = children.findIndex(
|
||||||
|
(child) =>
|
||||||
|
child.frigate?.recording &&
|
||||||
|
child.frigate.recording.start_time * 1000 === after.getTime(),
|
||||||
|
);
|
||||||
|
if (childIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.view
|
||||||
|
?.evolve({
|
||||||
|
view: 'event',
|
||||||
|
target: createEventParentForChildren(localize('common.recordings'), children),
|
||||||
|
childIndex: childIndex,
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever the range is in the process of being changed.
|
||||||
|
* @param properties
|
||||||
|
*/
|
||||||
|
protected _timelineRangeChangeHandler(
|
||||||
|
properties: TimelineEventPropertiesResult,
|
||||||
|
): void {
|
||||||
|
if (properties.event) {
|
||||||
|
// When a human changes the range, an event will be set.
|
||||||
|
this._wasDragged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever the timeline is clicked.
|
||||||
|
* @param properties The properties of the timeline click event.
|
||||||
|
*/
|
||||||
protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void {
|
protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void {
|
||||||
if (properties.what === 'item') {
|
if (properties.what && ['item', 'background'].includes(properties.what)) {
|
||||||
// Prevent interaction with items on the timeline from activating card
|
// Prevent interaction with items on the timeline from activating card
|
||||||
// wide actions.
|
// wide actions.
|
||||||
stopEventFromActivatingCardWideActions(properties.event);
|
stopEventFromActivatingCardWideActions(properties.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this._wasDragged && properties.what) {
|
||||||
|
if (['background', 'group-label'].includes(properties.what)) {
|
||||||
|
this._changeViewToRecording(properties.time, String(properties.group));
|
||||||
|
} else if (properties.what === 'axis') {
|
||||||
|
this._changeViewToRecording(properties.time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._wasDragged = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -468,14 +761,15 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
|
if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
|
||||||
this._events
|
this._data
|
||||||
.fetchEventsIfNecessary(
|
.fetchIfNecessary(
|
||||||
this,
|
this,
|
||||||
this.hass,
|
this.hass,
|
||||||
this.cameras,
|
this.cameras,
|
||||||
this.timelineConfig.media,
|
this.timelineConfig.media,
|
||||||
properties.start,
|
properties.start,
|
||||||
properties.end,
|
properties.end,
|
||||||
|
this.timelineConfig.show_recordings,
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (this._timeline) {
|
if (this._timeline) {
|
||||||
@@ -507,7 +801,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
|
|
||||||
const childIndex = data.items.length
|
const childIndex = data.items.length
|
||||||
? this.view.target.children.findIndex(
|
? this.view.target.children.findIndex(
|
||||||
(child) => child.frigate?.event.id === data.items[0],
|
(child) => child.frigate?.event?.id === data.items[0],
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -560,8 +854,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
const selected = this._timeline.getSelection();
|
const selected = this._timeline.getSelection();
|
||||||
let childIndex = -1;
|
let childIndex = -1;
|
||||||
const children: FrigateBrowseMediaSource[] = [];
|
const children: FrigateBrowseMediaSource[] = [];
|
||||||
this._events.dataset.get({ order: sortEvent }).forEach((item) => {
|
this._data.dataset.get({ order: sortEvent }).forEach((item) => {
|
||||||
if (item.source) {
|
if (item.event && item.source) {
|
||||||
children.push(item.source);
|
children.push(item.source);
|
||||||
if (selected.includes(item.event.id)) {
|
if (selected.includes(item.event.id)) {
|
||||||
childIndex = children.length - 1;
|
childIndex = children.length - 1;
|
||||||
@@ -572,9 +866,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = createEventParentForChildren('Timeline events', children);
|
|
||||||
return {
|
return {
|
||||||
target: target,
|
target: createEventParentForChildren('Timeline events', children),
|
||||||
childIndex: childIndex < 0 ? null : childIndex,
|
childIndex: childIndex < 0 ? null : childIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -695,12 +988,14 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// Never include the target media in a cluster, and never group
|
// Never include the target media in a cluster, and never group
|
||||||
// different object types together (e.g. person and car).
|
// different object types together (e.g. person and car).
|
||||||
return (
|
return (
|
||||||
|
[first.type, second.type].every((type) => type !== 'background') &&
|
||||||
|
first.type === second.type &&
|
||||||
!!first.id &&
|
!!first.id &&
|
||||||
first.id !== this.view?.media?.frigate?.event?.id &&
|
first.id !== this.view?.media?.frigate?.event?.id &&
|
||||||
!!second.id &&
|
!!second.id &&
|
||||||
second.id != this.view?.media?.frigate?.event?.id &&
|
second.id != this.view?.media?.frigate?.event?.id &&
|
||||||
(<FrigateCardTimelineItem>first).event.label ===
|
(<FrigateCardTimelineItem>first).event?.label ===
|
||||||
(<FrigateCardTimelineItem>second).event.label
|
(<FrigateCardTimelineItem>second).event?.label
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -750,13 +1045,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
* Update the timeline from the view object.
|
* Update the timeline from the view object.
|
||||||
*/
|
*/
|
||||||
protected async _updateTimelineFromView(): Promise<void> {
|
protected async _updateTimelineFromView(): Promise<void> {
|
||||||
if (
|
if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
|
||||||
!this.hass ||
|
|
||||||
!this.cameras ||
|
|
||||||
!this.view ||
|
|
||||||
!this._timeline ||
|
|
||||||
!this.timelineConfig
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,15 +1054,20 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
? this._getStartEndFromEvent(event)
|
? this._getStartEndFromEvent(event)
|
||||||
: this._getStartEnd();
|
: this._getStartEnd();
|
||||||
|
|
||||||
await this._events.fetchEventsIfNecessary(
|
await this._data.fetchIfNecessary(
|
||||||
this,
|
this,
|
||||||
this.hass,
|
this.hass,
|
||||||
this.cameras,
|
this.cameras,
|
||||||
this.timelineConfig.media,
|
this.timelineConfig.media,
|
||||||
windowStart,
|
windowStart,
|
||||||
windowEnd,
|
windowEnd,
|
||||||
|
this.timelineConfig.show_recordings,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!this._timeline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._timeline.setSelection(event ? [event.id] : [], {
|
this._timeline.setSelection(event ? [event.id] : [], {
|
||||||
focus: false,
|
focus: false,
|
||||||
animation: {
|
animation: {
|
||||||
@@ -807,9 +1101,9 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||||
// update the dataset to ensure the newly selected item cannot be included
|
// update the dataset to ensure the newly selected item cannot be included
|
||||||
// in a cluster.
|
// in a cluster.
|
||||||
const item = this._events.dataset.get(event.id);
|
const item = this._data.dataset.get(event.id);
|
||||||
if (item) {
|
if (item) {
|
||||||
this._events.dataset.updateOnly(item);
|
this._data.dataset.updateOnly(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -825,7 +1119,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// -> New view dispatched (to load thumbnails into outer carousel).
|
// -> New view dispatched (to load thumbnails into outer carousel).
|
||||||
// -> New view received ... [loop]
|
// -> New view received ... [loop]
|
||||||
const currentContext = this.view.context as TimelineViewContext | null;
|
const currentContext = this.view.context as TimelineViewContext | null;
|
||||||
if (currentContext?.dateFetch !== this._events.lastFetchDate) {
|
if (currentContext?.dateFetch !== this._data.lastFetchDate) {
|
||||||
const thumbnails = this._generateThumbnails();
|
const thumbnails = this._generateThumbnails();
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
@@ -851,8 +1145,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
} else if (currentContext?.window) {
|
} else if (currentContext?.window) {
|
||||||
newContext.window = currentContext.window;
|
newContext.window = currentContext.window;
|
||||||
}
|
}
|
||||||
if (this._events.lastFetchDate) {
|
if (this._data.lastFetchDate) {
|
||||||
newContext.dateFetch = this._events.lastFetchDate;
|
newContext.dateFetch = this._data.lastFetchDate;
|
||||||
}
|
}
|
||||||
return newContext || null;
|
return newContext || null;
|
||||||
}
|
}
|
||||||
@@ -865,7 +1159,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
||||||
if (changedProperties.has('cameras')) {
|
if (changedProperties.has('cameras')) {
|
||||||
this._events.clear();
|
this._data.clear();
|
||||||
this._timeline?.destroy();
|
this._timeline?.destroy();
|
||||||
this._timeline = undefined;
|
this._timeline = undefined;
|
||||||
}
|
}
|
||||||
@@ -888,14 +1182,14 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
|
|
||||||
this._timeline = new Timeline(
|
this._timeline = new Timeline(
|
||||||
this._refTimeline.value,
|
this._refTimeline.value,
|
||||||
this._events.dataset,
|
this._data.dataset,
|
||||||
groups,
|
groups,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
this._timeline.on('select', this._timelineSelectHandler.bind(this));
|
this._timeline.on('select', this._timelineSelectHandler.bind(this));
|
||||||
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
|
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
|
||||||
this._timeline.on('click', this._timelineClickHandler.bind(this));
|
this._timeline.on('click', this._timelineClickHandler.bind(this));
|
||||||
this._timeline.on('doubleclick', this._timelineClickHandler.bind(this));
|
this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-10
@@ -22,6 +22,7 @@ import type {
|
|||||||
CameraConfig,
|
CameraConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
|
FrigateCardMediaPlayer,
|
||||||
MediaShowInfo,
|
MediaShowInfo,
|
||||||
TransitionEffect,
|
TransitionEffect,
|
||||||
ViewerConfig
|
ViewerConfig
|
||||||
@@ -36,8 +37,8 @@ import {
|
|||||||
multipleBrowseMediaQueryMerged,
|
multipleBrowseMediaQueryMerged,
|
||||||
overrideMultiBrowseMediaQueryParameters
|
overrideMultiBrowseMediaQueryParameters
|
||||||
} from '../utils/ha/browse-media.js';
|
} from '../utils/ha/browse-media.js';
|
||||||
import { createMediaShowInfo } from '../utils/media-info.js';
|
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||||
|
import { createMediaShowInfo } from '../utils/media-info.js';
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||||
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
|
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
|
||||||
@@ -125,6 +126,8 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
|
||||||
|
|
||||||
@customElement('frigate-card-viewer-carousel')
|
@customElement('frigate-card-viewer-carousel')
|
||||||
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
@@ -168,11 +171,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
++i
|
++i
|
||||||
) {
|
) {
|
||||||
if (isTrueMedia(target.children[i])) {
|
if (isTrueMedia(target.children[i])) {
|
||||||
await resolveMedia(
|
await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
|
||||||
this.hass,
|
|
||||||
target.children[i],
|
|
||||||
this.resolvedMediaCache,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -278,6 +277,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The the HLS player on a slide (or current slide if not provided.)
|
||||||
|
* @param slide An optional slide.
|
||||||
|
* @returns The FrigateCardMediaPlayer or null if not found.
|
||||||
|
*/
|
||||||
|
protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null {
|
||||||
|
if (this._carousel) {
|
||||||
|
if (!slide) {
|
||||||
|
slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()];
|
||||||
|
}
|
||||||
|
return slide?.querySelector(
|
||||||
|
FRIGATE_CARD_HLS_SELECTOR,
|
||||||
|
) as FrigateCardMediaPlayer | null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Embla plugins to use.
|
* Get the Embla plugins to use.
|
||||||
* @returns An EmblaOptionsType object or undefined for no options.
|
* @returns An EmblaOptionsType object or undefined for no options.
|
||||||
@@ -291,7 +307,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
AutoMediaPlugin({
|
AutoMediaPlugin({
|
||||||
playerSelector: 'frigate-card-ha-hls-player',
|
playerSelector: FRIGATE_CARD_HLS_SELECTOR,
|
||||||
...(this.viewerConfig?.auto_play && {
|
...(this.viewerConfig?.auto_play && {
|
||||||
autoPlayCondition: this.viewerConfig.auto_play,
|
autoPlayCondition: this.viewerConfig.auto_play,
|
||||||
}),
|
}),
|
||||||
@@ -511,9 +527,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
const img = slide.querySelector('img') as HTMLImageElement;
|
const img = slide.querySelector('img') as HTMLImageElement;
|
||||||
|
|
||||||
// Frigate >= 0.9.0+ clips.
|
// Frigate >= 0.9.0+ clips.
|
||||||
const hls_player = slide.querySelector(
|
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
|
||||||
'frigate-card-ha-hls-player',
|
url: string;
|
||||||
) as HTMLElement & { url: string };
|
};
|
||||||
|
|
||||||
if (img) {
|
if (img) {
|
||||||
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
||||||
@@ -661,6 +677,24 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
: ``} `;
|
: ``} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a media show event when a slide is selected.
|
||||||
|
*/
|
||||||
|
protected _selectSlideMediaShowHandler(): void {
|
||||||
|
super._selectSlideMediaShowHandler();
|
||||||
|
|
||||||
|
// If this is a recording and play is desired to be started from a
|
||||||
|
// particular point, seek to that point.
|
||||||
|
if (this.view?.media?.frigate?.recording?.play_time) {
|
||||||
|
const player = this._getPlayer();
|
||||||
|
if (player) {
|
||||||
|
player.seek(this.view.media.frigate.recording.play_time);
|
||||||
|
// TODO: Fix this bug.
|
||||||
|
console.info(`Seeking on ${this.view.media.media_content_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected _renderMediaItem(
|
protected _renderMediaItem(
|
||||||
mediaToRender: FrigateBrowseMediaSource,
|
mediaToRender: FrigateBrowseMediaSource,
|
||||||
slideIndex: number,
|
slideIndex: number,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as
|
|||||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
||||||
`${CONF_TIMELINE}.clustering_threshold` as const;
|
`${CONF_TIMELINE}.clustering_threshold` as const;
|
||||||
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
|
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
|
||||||
|
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
|
||||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
|
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
|
||||||
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
|
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
|
||||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
|
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||||
CONF_TIMELINE_MEDIA,
|
CONF_TIMELINE_MEDIA,
|
||||||
|
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||||
CONF_TIMELINE_WINDOW_SECONDS,
|
CONF_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_VIEW_CAMERA_SELECT,
|
CONF_VIEW_CAMERA_SELECT,
|
||||||
CONF_VIEW_DARK_MODE,
|
CONF_VIEW_DARK_MODE,
|
||||||
@@ -1223,6 +1224,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_TIMELINE_MEDIA,
|
CONF_TIMELINE_MEDIA,
|
||||||
this._timelineMediaTypes,
|
this._timelineMediaTypes,
|
||||||
)}
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||||
|
defaults.timeline.show_recordings,
|
||||||
|
)}
|
||||||
${this._renderOptionSelector(
|
${this._renderOptionSelector(
|
||||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||||
this._thumbnailModes,
|
this._thumbnailModes,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"no_clips": "No clips",
|
"no_clips": "No clips",
|
||||||
"no_snapshot": "No recent snapshot",
|
"no_snapshot": "No recent snapshot",
|
||||||
"no_clip": "No recent clip",
|
"no_clip": "No recent clip",
|
||||||
"live": "Live"
|
"live": "Live",
|
||||||
|
"recordings": "Recordings"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"cameras": {
|
"cameras": {
|
||||||
@@ -109,8 +110,8 @@
|
|||||||
"thumbnails": {
|
"thumbnails": {
|
||||||
"mode": "Event Viewer thumbnails mode",
|
"mode": "Event Viewer thumbnails mode",
|
||||||
"size": "Event Viewer thumbnails size in pixels",
|
"size": "Event Viewer thumbnails size in pixels",
|
||||||
"show_details": "Show event details with thumbnails",
|
"show_details": "Show details with thumbnails",
|
||||||
"show_controls": "Show event controls with thumbnails",
|
"show_controls": "Show controls with thumbnails",
|
||||||
"modes": {
|
"modes": {
|
||||||
"below": "Thumbnails below the media",
|
"below": "Thumbnails below the media",
|
||||||
"above": "Thumbnails above the media",
|
"above": "Thumbnails above the media",
|
||||||
@@ -229,6 +230,7 @@
|
|||||||
"window_seconds": "The default length of the timeline view in seconds",
|
"window_seconds": "The default length of the timeline view in seconds",
|
||||||
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
|
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
|
||||||
"media": "The media the timeline displays",
|
"media": "The media the timeline displays",
|
||||||
|
"show_recordings": "Show recordings",
|
||||||
"medias": {
|
"medias": {
|
||||||
"all": "All media types",
|
"all": "All media types",
|
||||||
"clips": "Clips",
|
"clips": "Clips",
|
||||||
@@ -292,13 +294,20 @@
|
|||||||
"in_progress": "In Progress",
|
"in_progress": "In Progress",
|
||||||
"score": "Score"
|
"score": "Score"
|
||||||
},
|
},
|
||||||
|
"recording": {
|
||||||
|
"events": "Events"
|
||||||
|
},
|
||||||
"thumbnail": {
|
"thumbnail": {
|
||||||
"retain_indefinitely": "Event will be indefinitely retained",
|
"retain_indefinitely": "Event will be indefinitely retained",
|
||||||
"timeline": "See event in timeline"
|
"timeline": "See event in timeline",
|
||||||
|
"no_thumbnail": "No thumbnail available"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
"undecodable_response": "Could not decode response from Home Assistant for request",
|
||||||
"empty_response": "Received empty response from Home Assistant for request",
|
"empty_response": "Received empty response from Home Assistant for request",
|
||||||
"invalid_response": "Received invalid response from Home Assistant for request",
|
"invalid_response": "Received invalid response from Home Assistant for request",
|
||||||
|
"failed_response": "Failed to receive response from Home Assistant for request",
|
||||||
|
"failed_sign": "Could not sign Home Assistant URL",
|
||||||
"invalid_keys": "Invalid keys",
|
"invalid_keys": "Invalid keys",
|
||||||
"unknown": "Unknown error",
|
"unknown": "Unknown error",
|
||||||
"troubleshooting": "Check troubleshooting",
|
"troubleshooting": "Check troubleshooting",
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
this._player?.unmute();
|
this._player?.unmute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video (unsupported).
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
this._player?.seek(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video.
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
if (this._video) {
|
||||||
|
this._video.currentTime = seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video.
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
if (this._video) {
|
||||||
|
this._video.currentTime = seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
// Ensure error messages are selectable.
|
||||||
|
user-select: text;
|
||||||
|
// Safari only has prefixed support.
|
||||||
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.wrapper {
|
div.wrapper {
|
||||||
@@ -20,6 +25,7 @@ div.message {
|
|||||||
div.message div.contents {
|
div.message div.contents {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.message div.icon {
|
div.message div.icon {
|
||||||
@@ -37,4 +43,6 @@ div.message div.icon {
|
|||||||
|
|
||||||
.message pre {
|
.message pre {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
box-shadow: 0px 0px 20px 5px black;
|
box-shadow: 0px 0px 20px 5px black;
|
||||||
transition: all 0.2s ease-out;
|
transition: all 0.2s ease-out;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
.controls.thumbnails:hover {
|
.controls.thumbnails:hover {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
|
|||||||
@@ -8,11 +8,15 @@
|
|||||||
column-gap: 5%;
|
column-gap: 5%;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.right, div.left {
|
div.right,
|
||||||
|
div.left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
div.right {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
div.left {
|
div.left {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -36,6 +40,7 @@ span.heading {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.larger {
|
div.larger,
|
||||||
|
span.larger {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
img, ha-icon {
|
||||||
|
border-radius: var(--ha-card-border-radius, 4px);
|
||||||
|
|
||||||
|
max-width: var(--frigate-card-thumbnail-size-max);
|
||||||
|
max-height: var(--frigate-card-thumbnail-size-max);
|
||||||
|
|
||||||
|
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
|
||||||
|
// should be clipped to fill the thumbnail div whilst maintaining
|
||||||
|
// aspect-ratio.
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
// Restrict images to a maximum of thumbnail size.
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
transition: transform 0.2s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha-icon {
|
||||||
|
--mdc-icon-size: 50%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
img:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
max-width: var(--frigate-card-thumbnail-size-max);
|
||||||
|
max-height: var(--frigate-card-thumbnail-size-max);
|
||||||
|
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: var(--ha-card-border-radius, 4px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
transition: transform 0.2s linear;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(:hover) {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
+8
-25
@@ -19,39 +19,22 @@
|
|||||||
background-color: var(--primary-background-color, black);
|
background-color: var(--primary-background-color, black);
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
ha-icon {
|
||||||
border-radius: var(--ha-card-border-radius, 4px);
|
position: absolute;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
|
border-radius: 50%;
|
||||||
// should be clipped to fill the thumbnail div whilst maintaining
|
|
||||||
// aspect-ratio.
|
|
||||||
object-fit: cover;
|
|
||||||
|
|
||||||
// Restrict images to a maximum of thumbnail size.
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
max-width: var(--frigate-card-thumbnail-size-max);
|
|
||||||
max-height: var(--frigate-card-thumbnail-size-max);
|
|
||||||
|
|
||||||
transition: transform 0.2s linear;
|
|
||||||
}
|
|
||||||
img:hover {
|
|
||||||
transform: scale(1.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ha-icon.favorite {
|
ha-icon.favorite {
|
||||||
position: absolute;
|
|
||||||
color: gold;
|
color: gold;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
ha-icon.timeline {
|
ha-icon.timeline {
|
||||||
position: absolute;
|
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
right: 0px;
|
right: 0px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
}
|
||||||
border-radius: 50%;
|
|
||||||
|
frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -46,10 +46,18 @@ div.timeline.right-margin {
|
|||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
.vis-item.vis-background {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.vis-item:hover {
|
.vis-item:not(.vis-background) {
|
||||||
// Float icons upwards when the user hovers over them.
|
cursor: pointer;
|
||||||
z-index: 2;
|
}
|
||||||
|
.vis-item.vis-background, .vis-labelset, .vis-time-axis {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.vis-item:active {
|
||||||
|
cursor: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vis-item.vis-box {
|
.vis-item.vis-box {
|
||||||
|
|||||||
+48
-8
@@ -83,7 +83,14 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
|
|||||||
export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
|
export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
|
||||||
export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
|
export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
|
||||||
|
|
||||||
export class FrigateCardError extends Error {}
|
export class FrigateCardError extends Error {
|
||||||
|
context?: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, context?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action Types (for "Picture Elements" / Menu)
|
* Action Types (for "Picture Elements" / Menu)
|
||||||
@@ -400,7 +407,10 @@ const cameraConfigSchema = z
|
|||||||
|
|
||||||
trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion),
|
trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion),
|
||||||
trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy),
|
trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy),
|
||||||
trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities),
|
trigger_by_entities: z
|
||||||
|
.string()
|
||||||
|
.array()
|
||||||
|
.default(cameraConfigDefault.trigger_by_entities),
|
||||||
})
|
})
|
||||||
.default(cameraConfigDefault);
|
.default(cameraConfigDefault);
|
||||||
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
||||||
@@ -508,7 +518,7 @@ const viewConfigDefault = {
|
|||||||
scan: {
|
scan: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
show_trigger_status: true,
|
show_trigger_status: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
const viewConfigSchema = z
|
const viewConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -525,10 +535,14 @@ const viewConfigSchema = z
|
|||||||
update_entities: z.string().array().optional(),
|
update_entities: z.string().array().optional(),
|
||||||
render_entities: z.string().array().optional(),
|
render_entities: z.string().array().optional(),
|
||||||
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
|
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
|
||||||
scan: z.object({
|
scan: z
|
||||||
|
.object({
|
||||||
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
|
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
|
||||||
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
|
show_trigger_status: z
|
||||||
}).default(viewConfigDefault.scan)
|
.boolean()
|
||||||
|
.default(viewConfigDefault.scan.show_trigger_status),
|
||||||
|
})
|
||||||
|
.default(viewConfigDefault.scan),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema)
|
.merge(actionsSchema)
|
||||||
.default(viewConfigDefault);
|
.default(viewConfigDefault);
|
||||||
@@ -970,6 +984,7 @@ const timelineConfigDefault = {
|
|||||||
clustering_threshold: 3,
|
clustering_threshold: 3,
|
||||||
media: 'all' as const,
|
media: 'all' as const,
|
||||||
window_seconds: 60 * 60,
|
window_seconds: 60 * 60,
|
||||||
|
show_recordings: true,
|
||||||
controls: {
|
controls: {
|
||||||
thumbnails: {
|
thumbnails: {
|
||||||
mode: 'left' as const,
|
mode: 'left' as const,
|
||||||
@@ -995,6 +1010,7 @@ const timelineConfigSchema = z
|
|||||||
.max(24 * 60 * 60)
|
.max(24 * 60 * 60)
|
||||||
.optional()
|
.optional()
|
||||||
.default(timelineConfigDefault.window_seconds),
|
.default(timelineConfigDefault.window_seconds),
|
||||||
|
show_recordings: z.boolean().optional().default(timelineConfigDefault.show_recordings),
|
||||||
controls: z
|
controls: z
|
||||||
.object({
|
.object({
|
||||||
thumbnails: thumbnailsControlSchema
|
thumbnails: thumbnailsControlSchema
|
||||||
@@ -1130,6 +1146,15 @@ export interface BrowseMediaQueryParameters {
|
|||||||
cameraID?: string;
|
cameraID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowseRecordingQueryParameters {
|
||||||
|
clientId: string;
|
||||||
|
cameraName: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BrowseMediaNeighbors {
|
export interface BrowseMediaNeighbors {
|
||||||
previous: FrigateBrowseMediaSource | null;
|
previous: FrigateBrowseMediaSource | null;
|
||||||
previousIndex: number | null;
|
previousIndex: number | null;
|
||||||
@@ -1165,6 +1190,7 @@ export interface FrigateCardMediaPlayer {
|
|||||||
pause(): void;
|
pause(): void;
|
||||||
mute(): void;
|
mute(): void;
|
||||||
unmute(): void;
|
unmute(): void;
|
||||||
|
seek(seconds: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardHelpers {
|
export interface CardHelpers {
|
||||||
@@ -1180,7 +1206,9 @@ export interface CardHelpers {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
|
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
|
||||||
|
export const MEDIA_CLASS_VIDEO = 'video' as const;
|
||||||
export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
|
export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
|
||||||
|
export const MEDIA_TYPE_VIDEO = 'video' as const;
|
||||||
|
|
||||||
// Recursive type, cannot use type interference:
|
// Recursive type, cannot use type interference:
|
||||||
// See: https://github.com/colinhacks/zod#recursive-types
|
// See: https://github.com/colinhacks/zod#recursive-types
|
||||||
@@ -1212,10 +1240,22 @@ export interface FrigateEvent {
|
|||||||
retain_indefinitely?: boolean;
|
retain_indefinitely?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FrigateRecording {
|
||||||
|
camera: string;
|
||||||
|
start_time: number;
|
||||||
|
end_time: number;
|
||||||
|
events: number;
|
||||||
|
|
||||||
|
// The number of seconds at which this recording should be initially played
|
||||||
|
// from.
|
||||||
|
play_time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
||||||
children?: FrigateBrowseMediaSource[] | null;
|
children?: FrigateBrowseMediaSource[] | null;
|
||||||
frigate?: {
|
frigate?: {
|
||||||
event: FrigateEvent;
|
event?: FrigateEvent;
|
||||||
|
recording?: FrigateRecording;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1274,7 +1314,7 @@ export type Entity = z.infer<typeof entitySchema>;
|
|||||||
export const extendedEntitySchema = entitySchema.extend({
|
export const extendedEntitySchema = entitySchema.extend({
|
||||||
// Extended entity results.
|
// Extended entity results.
|
||||||
unique_id: z.string().optional(),
|
unique_id: z.string().optional(),
|
||||||
})
|
});
|
||||||
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
|
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
|
||||||
|
|
||||||
export const entityListSchema = entitySchema.array();
|
export const entityListSchema = entitySchema.array();
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ExtendedHomeAssistant } from '../types';
|
||||||
|
import { homeAssistantHTTPRequest } from './ha';
|
||||||
|
|
||||||
|
const recordingSummaryHourSchema = z.object({
|
||||||
|
hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
|
||||||
|
duration: z.number().min(0),
|
||||||
|
events: z.number().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordingSummarySchema = z
|
||||||
|
.object({
|
||||||
|
day: z.preprocess((arg) => {
|
||||||
|
// Must provide the hour:minute:second on parsing or Javascript will
|
||||||
|
// assume UTC midnight.
|
||||||
|
return typeof arg === 'string' ? new Date(`${arg} 00:00:00`) : arg;
|
||||||
|
}, z.date()),
|
||||||
|
events: z.number(),
|
||||||
|
hours: recordingSummaryHourSchema.array(),
|
||||||
|
})
|
||||||
|
.array();
|
||||||
|
export type RecordingSummary = z.infer<typeof recordingSummarySchema>;
|
||||||
|
|
||||||
|
const recordingSegmentSchema = z.object({
|
||||||
|
start_time: z.number(),
|
||||||
|
end_time: z.number(),
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
const recordingSegmentsSchema = recordingSegmentSchema.array();
|
||||||
|
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the recordings summary.
|
||||||
|
* @param hass The Home Assistant object.
|
||||||
|
* @param client_id The Frigate client_id.
|
||||||
|
* @param camera_name The Frigate camera name.
|
||||||
|
* @returns A RecordingSummary object.
|
||||||
|
*/
|
||||||
|
export const getRecordingsSummary = async (
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
client_id: string,
|
||||||
|
camera_name: string,
|
||||||
|
): Promise<RecordingSummary> => {
|
||||||
|
return await homeAssistantHTTPRequest(
|
||||||
|
hass,
|
||||||
|
recordingSummarySchema,
|
||||||
|
`/api/frigate/${client_id}/${camera_name}/recordings/summary`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the recording segments..
|
||||||
|
* @param hass The Home Assistant object.
|
||||||
|
* @param client_id The Frigate client_id.
|
||||||
|
* @param camera_name The Frigate camera name.
|
||||||
|
* @param before The segment low watermark.
|
||||||
|
* @param after The segment high watermark.
|
||||||
|
* @returns A RecordingSegments object.
|
||||||
|
*/
|
||||||
|
export const getRecordingSegments = async (
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
client_id: string,
|
||||||
|
camera_name: string,
|
||||||
|
before: Date,
|
||||||
|
after: Date,
|
||||||
|
): Promise<RecordingSegments> => {
|
||||||
|
return await homeAssistantHTTPRequest(
|
||||||
|
hass,
|
||||||
|
recordingSegmentsSchema,
|
||||||
|
`/api/frigate/${client_id}/${camera_name}/recordings`,
|
||||||
|
new URLSearchParams({
|
||||||
|
before: String(before.getTime() / 1000),
|
||||||
|
after: String(after.getTime() / 1000),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,15 +8,23 @@ import {
|
|||||||
import { homeAssistantWSRequest } from '.';
|
import { homeAssistantWSRequest } from '.';
|
||||||
import {
|
import {
|
||||||
dispatchErrorMessageEvent,
|
dispatchErrorMessageEvent,
|
||||||
|
dispatchFrigateCardErrorEvent,
|
||||||
dispatchMessageEvent
|
dispatchMessageEvent
|
||||||
} from '../../components/message.js';
|
} from '../../components/message.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import {
|
import {
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
|
BrowseRecordingQueryParameters,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
frigateBrowseMediaSourceSchema, FrigateEvent, MEDIA_CLASS_PLAYLIST,
|
frigateBrowseMediaSourceSchema,
|
||||||
MEDIA_TYPE_PLAYLIST
|
FrigateCardError,
|
||||||
|
FrigateEvent,
|
||||||
|
FrigateRecording,
|
||||||
|
MEDIA_CLASS_PLAYLIST,
|
||||||
|
MEDIA_CLASS_VIDEO,
|
||||||
|
MEDIA_TYPE_PLAYLIST,
|
||||||
|
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 { getCameraTitle } from '../camera.js';
|
||||||
@@ -27,7 +35,7 @@ import { getCameraTitle } from '../camera.js';
|
|||||||
* @returns The `event_id` or `null` if not successfully parsed.
|
* @returns The `event_id` or `null` if not successfully parsed.
|
||||||
*/
|
*/
|
||||||
export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
|
export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
|
||||||
return media.frigate?.event.id ?? null;
|
return media.frigate?.event?.id ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +44,7 @@ export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
|
|||||||
* @returns The start time in unix/epoch time, or null if it cannot be determined.
|
* @returns The start time in unix/epoch time, or null if it cannot be determined.
|
||||||
*/
|
*/
|
||||||
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
|
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
|
||||||
return media.frigate?.event.start_time ?? null;
|
return media.frigate?.event?.start_time ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -336,7 +344,7 @@ export const fetchLatestMediaAndDispatchViewChange = async (
|
|||||||
try {
|
try {
|
||||||
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
|
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return dispatchErrorMessageEvent(element, (e as Error).message);
|
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||||
}
|
}
|
||||||
const childIndex = getFirstTrueMediaChildIndex(parent);
|
const childIndex = getFirstTrueMediaChildIndex(parent);
|
||||||
if (!parent || !parent.children || childIndex == null) {
|
if (!parent || !parent.children || childIndex == null) {
|
||||||
@@ -376,7 +384,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
|
|||||||
try {
|
try {
|
||||||
parent = await browseMedia(hass, child.media_content_id);
|
parent = await browseMedia(hass, child.media_content_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return dispatchErrorMessageEvent(element, (e as Error).message);
|
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||||
}
|
}
|
||||||
|
|
||||||
view
|
view
|
||||||
@@ -409,6 +417,38 @@ export const createEventParentForChildren = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a media video child with a given media_content_id.
|
||||||
|
* @param title The title to use for the child.
|
||||||
|
* @param media_con
|
||||||
|
* @param children The children media items.
|
||||||
|
* @returns A single parent containing the children.
|
||||||
|
*/
|
||||||
|
export const createVideoChild = (
|
||||||
|
title: string,
|
||||||
|
mediaContentID: string,
|
||||||
|
options?: {
|
||||||
|
thumbnail?: string;
|
||||||
|
recording?: FrigateRecording;
|
||||||
|
},
|
||||||
|
): FrigateBrowseMediaSource => {
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
media_class: MEDIA_CLASS_VIDEO,
|
||||||
|
media_content_type: MEDIA_TYPE_VIDEO,
|
||||||
|
media_content_id: mediaContentID,
|
||||||
|
can_play: true,
|
||||||
|
can_expand: false,
|
||||||
|
thumbnail: options?.thumbnail ?? null,
|
||||||
|
children: null,
|
||||||
|
...(options?.recording && {
|
||||||
|
frigate: {
|
||||||
|
recording: options.recording,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience function to convert a timestamp to hours, minutes and seconds
|
* Convenience function to convert a timestamp to hours, minutes and seconds
|
||||||
* string. Heavily inspired by, and returning the same format as, the Frigate
|
* string. Heavily inspired by, and returning the same format as, the Frigate
|
||||||
@@ -436,3 +476,23 @@ export function getEventDurationString(event: FrigateEvent): string {
|
|||||||
duration += `${seconds}s`;
|
duration += `${seconds}s`;
|
||||||
return duration;
|
return duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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('/');
|
||||||
|
};
|
||||||
|
|||||||
+79
-9
@@ -4,7 +4,9 @@ import { StyleInfo } from 'lit/directives/style-map.js';
|
|||||||
import { ZodSchema } from 'zod';
|
import { ZodSchema } from 'zod';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import {
|
import {
|
||||||
CardHelpers, ExtendedHomeAssistant,
|
CardHelpers,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
FrigateCardError,
|
||||||
SignedPath,
|
SignedPath,
|
||||||
signedPathSchema,
|
signedPathSchema,
|
||||||
StateParameters
|
StateParameters
|
||||||
@@ -36,12 +38,16 @@ export async function homeAssistantWSRequest<T>(
|
|||||||
const parseResult = schema.safeParse(response);
|
const parseResult = schema.safeParse(response);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
const keys = getParseErrorKeys<T>(parseResult.error);
|
const keys = getParseErrorKeys<T>(parseResult.error);
|
||||||
const error_message =
|
const error_message = localize('error.invalid_response');
|
||||||
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
|
console.warn(
|
||||||
localize('error.invalid_keys') +
|
`${error_message}: ${JSON.stringify(request)}. ${localize(
|
||||||
`: '${keys}'`;
|
'error.invalid_keys',
|
||||||
console.warn(error_message);
|
)}: ${keys}`,
|
||||||
throw new Error(error_message);
|
);
|
||||||
|
throw new FrigateCardError(error_message, {
|
||||||
|
request: request,
|
||||||
|
invalid_keys: keys,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return parseResult.data;
|
return parseResult.data;
|
||||||
}
|
}
|
||||||
@@ -74,6 +80,70 @@ export async function homeAssistantSignPath(
|
|||||||
return hass.hassUrl(response.path);
|
return hass.hassUrl(response.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a HomeAssistant HTTP request. May throw.
|
||||||
|
* @param hass The HomeAssistant object to send the request with.
|
||||||
|
* @param schema The expected Zod schema of the response.
|
||||||
|
* @param request The request to make.
|
||||||
|
* @returns The parsed valid response or null on malformed.
|
||||||
|
*/
|
||||||
|
export async function homeAssistantHTTPRequest<T>(
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
schema: ZodSchema<T>,
|
||||||
|
url: string,
|
||||||
|
params?: URLSearchParams,
|
||||||
|
): Promise<T> {
|
||||||
|
let signResponse: string | null | undefined;
|
||||||
|
try {
|
||||||
|
signResponse = await homeAssistantSignPath(hass, url);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signResponse) {
|
||||||
|
throw new FrigateCardError(localize('error.failed_sign'), {
|
||||||
|
url: url.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedURL = new URL(signResponse);
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of params.entries()) {
|
||||||
|
signedURL.searchParams.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(signedURL.toString());
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new FrigateCardError(localize('error.failed_response'), {
|
||||||
|
url: signedURL.toString(),
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_json;
|
||||||
|
try {
|
||||||
|
raw_json = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
throw new FrigateCardError(localize('error.undecodable_response'), {
|
||||||
|
url: signedURL.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return schema.parse(raw_json);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
throw new FrigateCardError(localize('error.invalid_response'), {
|
||||||
|
url: signedURL.toString(),
|
||||||
|
response: raw_json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface HassStateDifference {
|
interface HassStateDifference {
|
||||||
entity: string;
|
entity: string;
|
||||||
oldState?: HassEntity;
|
oldState?: HassEntity;
|
||||||
@@ -310,7 +380,7 @@ export const isTriggeredState = (state?: HassEntity): boolean => {
|
|||||||
* Get entities from the HASS object.
|
* Get entities from the HASS object.
|
||||||
* @param hass
|
* @param hass
|
||||||
* @param domain
|
* @param domain
|
||||||
* @returns
|
* @returns A list of entities ids.
|
||||||
*/
|
*/
|
||||||
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
|
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
|
||||||
if (!hass) {
|
if (!hass) {
|
||||||
@@ -321,4 +391,4 @@ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): strin
|
|||||||
);
|
);
|
||||||
entities.sort();
|
entities.sort();
|
||||||
return entities;
|
return entities;
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user