Initial draft of recordings support.
This commit is contained in:
+2
-2
@@ -975,7 +975,7 @@ export class FrigateCard extends LitElement {
|
||||
* @returns A boolean indicating whether the camera was changed.
|
||||
*/
|
||||
protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean {
|
||||
if (!this._view) {
|
||||
if (!this._view || !this._isAutomatedViewUpdateAllowed(true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1001,7 +1001,7 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) {
|
||||
if (triggerChanges) {
|
||||
if (!this._triggers.size) {
|
||||
this._changeView();
|
||||
changedCamera = true;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio
|
||||
import { localize } from '../localize/localize.js';
|
||||
import elementsStyle from '../scss/elements.scss';
|
||||
import {
|
||||
FrigateCardError,
|
||||
FrigateConditional,
|
||||
MenuButton,
|
||||
MenuIcon,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
PictureElements
|
||||
} from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||
|
||||
/* A note on picture element rendering:
|
||||
*
|
||||
@@ -108,7 +109,7 @@ export class FrigateCardElementsCore extends LitElement {
|
||||
element.setConfig(config);
|
||||
} catch (e) {
|
||||
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;
|
||||
}
|
||||
@@ -125,7 +126,7 @@ export class FrigateCardElementsCore extends LitElement {
|
||||
this._root = this._createRoot();
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
this._providerRef.value?.seek(seconds);
|
||||
}
|
||||
|
||||
protected _getResolvedProvider(): LiveProvider {
|
||||
if (this.cameraConfig?.live_provider === 'auto') {
|
||||
if (
|
||||
@@ -711,6 +718,13 @@ export class FrigateCardLiveFrigate extends LitElement {
|
||||
this._playerRef.value?.unmute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
this._playerRef.value?.seek(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @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.
|
||||
* @returns The player or `null` if not found.
|
||||
@@ -867,8 +891,9 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
|
||||
return dispatchErrorMessageEvent(
|
||||
this,
|
||||
e instanceof FrigateCardError
|
||||
? (e as FrigateCardError).message
|
||||
? e.message
|
||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||
(e as FrigateCardError).context
|
||||
);
|
||||
}
|
||||
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.
|
||||
* @returns A URL or null.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import messageStyle from '../scss/message.scss';
|
||||
import { Message } from '../types.js';
|
||||
import { FrigateCardError, Message } from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
|
||||
@customElement('frigate-card-message')
|
||||
@@ -140,3 +140,19 @@ export function dispatchErrorMessageEvent(
|
||||
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 {
|
||||
BrowseMediaQueryParameters,
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateCardError,
|
||||
FrigateCardView,
|
||||
ThumbnailsControlConfig
|
||||
} from '../types.js';
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
multipleBrowseMediaQueryMerged
|
||||
} from '../utils/ha/browse-media';
|
||||
import { View } from '../view.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||
import './surround.js';
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
|
||||
@@ -69,7 +70,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
try {
|
||||
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(this, (e as Error).message);
|
||||
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
||||
}
|
||||
if (getFirstTrueMediaChildIndex(parent) !== null) {
|
||||
this.view
|
||||
@@ -149,6 +150,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
view: this.targetView || 'event',
|
||||
target: ev.detail.target,
|
||||
childIndex: ev.detail.childIndex,
|
||||
context: null,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}}
|
||||
|
||||
+127
-31
@@ -3,8 +3,14 @@ import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
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 type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js';
|
||||
import type {
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateEvent,
|
||||
FrigateRecording,
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { prettifyTitle } from '../utils/basic.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.
|
||||
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
@customElement('frigate-card-thumbnail-details')
|
||||
export class FrigateCardThumbnailDetails extends LitElement {
|
||||
@customElement('frigate-card-thumbnail-feature-event')
|
||||
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 })
|
||||
public event?: FrigateEvent;
|
||||
|
||||
@@ -39,9 +86,29 @@ export class FrigateCardThumbnailDetails extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResult {
|
||||
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 {
|
||||
return unsafeCSS(thumbnailDetailsStyle);
|
||||
}
|
||||
@@ -90,6 +157,7 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
let event: FrigateEvent | null = null;
|
||||
let recording: FrigateRecording | null = null;
|
||||
let thumbnail: 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) {
|
||||
const media = this.target.children[this.childIndex];
|
||||
event = media.frigate?.event ?? null;
|
||||
recording = media.frigate?.recording ?? null;
|
||||
thumbnail = media.thumbnail;
|
||||
label = media.title;
|
||||
}
|
||||
@@ -108,34 +177,46 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
|
||||
label = this.label ? this.label : label;
|
||||
|
||||
if (!thumbnail) {
|
||||
if (!event && !recording) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html` <img
|
||||
aria-label="${label ?? ''}"
|
||||
src="${thumbnail}"
|
||||
title="${label ?? ''}"
|
||||
/>
|
||||
${this.controls && event?.retain_indefinitely
|
||||
? html` <ha-icon
|
||||
return html` ${event
|
||||
? html`<frigate-card-thumbnail-feature-event
|
||||
aria-label="${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
|
||||
? html` <ha-icon
|
||||
class="favorite"
|
||||
icon="mdi:star"
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
/>`
|
||||
: ``}
|
||||
${this.details && event
|
||||
? html`<frigate-card-thumbnail-details
|
||||
.event=${event}
|
||||
></frigate-card-thumbnail-details>`
|
||||
: html``}
|
||||
${this.controls
|
||||
? html`<ha-icon
|
||||
class="timeline"
|
||||
icon="mdi:target"
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
/></ha-icon>`
|
||||
: ``}
|
||||
${this.details && event
|
||||
? html`<frigate-card-thumbnail-details-event
|
||||
.event=${event ?? undefined}
|
||||
></frigate-card-thumbnail-details-event>`
|
||||
: this.details && recording
|
||||
? html`<frigate-card-thumbnail-details-recording
|
||||
.recording=${recording ?? undefined}
|
||||
></frigate-card-thumbnail-details-recording>`
|
||||
: html``}
|
||||
${this.controls
|
||||
? html`<ha-icon
|
||||
class="timeline"
|
||||
icon="mdi:target"
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (event) {
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'timeline',
|
||||
@@ -144,9 +225,24 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
context: {},
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}}
|
||||
></ha-icon>`
|
||||
: ''}`;
|
||||
} 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>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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 { add, fromUnixTime, sub } from 'date-fns';
|
||||
import {
|
||||
add,
|
||||
endOfHour,
|
||||
format,
|
||||
fromUnixTime,
|
||||
getUnixTime,
|
||||
startOfHour,
|
||||
sub
|
||||
} from 'date-fns';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
@@ -29,32 +39,44 @@ import timelineStyle from '../scss/timeline.scss';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateCardConfigDefaults,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
TimelineConfig
|
||||
} from '../types';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||
import { dispatchFrigateCardEvent, prettifyTitle } from '../utils/basic';
|
||||
import { getCameraTitle } from '../utils/camera.js';
|
||||
import {
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
RecordingSegments,
|
||||
RecordingSummary
|
||||
} from '../utils/frigate';
|
||||
import {
|
||||
createEventParentForChildren,
|
||||
createVideoChild,
|
||||
generateRecordingIdentifier,
|
||||
getBrowseMediaQueryParameters,
|
||||
isTrueMedia,
|
||||
multipleBrowseMediaQuery
|
||||
} from '../utils/ha/browse-media';
|
||||
import { View, ViewContext } from '../view';
|
||||
import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js';
|
||||
import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js';
|
||||
import './surround-thumbnails.js';
|
||||
|
||||
const TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS = 10;
|
||||
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
||||
|
||||
interface FrigateCardGroupData {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
interface FrigateCardTimelineItem extends TimelineItem {
|
||||
event: FrigateEvent;
|
||||
start: number;
|
||||
end?: number;
|
||||
event?: FrigateEvent;
|
||||
source?: FrigateBrowseMediaSource;
|
||||
}
|
||||
|
||||
@@ -68,12 +90,17 @@ interface TimelineViewContext extends ViewContext {
|
||||
|
||||
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
|
||||
|
||||
interface CameraRecordings {
|
||||
segments: RecordingSegments;
|
||||
summary: RecordingSummary;
|
||||
}
|
||||
|
||||
const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||
|
||||
/**
|
||||
* A manager to maintain/fetch timeline events.
|
||||
*/
|
||||
class TimelineEventManager {
|
||||
class TimelineDataManager {
|
||||
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
||||
|
||||
// The earliest date managed.
|
||||
@@ -87,7 +114,7 @@ class TimelineEventManager {
|
||||
|
||||
// The maximum allowable age of fetch data (will not fetch more frequently
|
||||
// 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 _tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
|
||||
@@ -183,9 +210,7 @@ class TimelineEventManager {
|
||||
* @param end An optional end of the date range.
|
||||
* @returns
|
||||
*/
|
||||
public hasCoverage(start: Date, end?: Date): boolean {
|
||||
const now = new Date().getTime();
|
||||
|
||||
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
|
||||
// Never fetched: no coverage.
|
||||
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
|
||||
return false;
|
||||
@@ -194,7 +219,7 @@ class TimelineEventManager {
|
||||
// If the most recent fetch is older than maxAgeSeconds: no coverage.
|
||||
if (
|
||||
this._maxAgeSeconds &&
|
||||
now - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
||||
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -219,7 +244,7 @@ class TimelineEventManager {
|
||||
return false;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -237,21 +262,108 @@ class TimelineEventManager {
|
||||
* @param end Fetch events that start earlier than this date.
|
||||
* @returns `true` if events were fetched, `false` otherwise.
|
||||
*/
|
||||
public async fetchEventsIfNecessary(
|
||||
public async fetchIfNecessary(
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
hass: ExtendedHomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: TimelineMediaType,
|
||||
eventMedia: TimelineMediaType,
|
||||
start: Date,
|
||||
end: Date,
|
||||
recordings?: boolean,
|
||||
): Promise<boolean> {
|
||||
if (this.hasCoverage(start, end)) {
|
||||
const now = new Date();
|
||||
if (this.hasCoverage(now, start, end)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param element The element to send error events from.
|
||||
@@ -265,34 +377,16 @@ class TimelineEventManager {
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: TimelineMediaType,
|
||||
start?: Date,
|
||||
end?: Date,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): 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[] = [];
|
||||
cameras.forEach((cameraConfig, cameraID) => {
|
||||
(media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
|
||||
if (
|
||||
this._dateEnd &&
|
||||
this._dateStart &&
|
||||
cameraConfig.camera_name !== CAMERA_BIRDSEYE
|
||||
) {
|
||||
if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) {
|
||||
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
|
||||
// Events are always fetched for the maximum extent of the managed
|
||||
// range. This is because events may change at any point in time
|
||||
// (e.g. a long-running event that ends).
|
||||
before: this._dateEnd.getTime() / 1000,
|
||||
after: this._dateStart.getTime() / 1000,
|
||||
before: end.getTime() / 1000,
|
||||
after: start.getTime() / 1000,
|
||||
unlimited: true,
|
||||
mediaType: mediaType as 'clips' | 'snapshots',
|
||||
});
|
||||
@@ -311,7 +405,7 @@ class TimelineEventManager {
|
||||
try {
|
||||
results = await multipleBrowseMediaQuery(hass, params);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(element, (e as Error).message);
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
|
||||
for (const [query, result] of results.entries()) {
|
||||
@@ -325,7 +419,7 @@ class TimelineEventManager {
|
||||
@customElement('frigate-card-timeline')
|
||||
export class FrigateCardTimeline extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant;
|
||||
protected hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
@@ -371,7 +465,7 @@ export class FrigateCardTimeline extends LitElement {
|
||||
@customElement('frigate-card-timeline-core')
|
||||
export class FrigateCardTimelineCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant;
|
||||
protected hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
@@ -382,12 +476,17 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected timelineConfig?: TimelineConfig;
|
||||
|
||||
protected _events = new TimelineEventManager({
|
||||
protected _data = new TimelineDataManager({
|
||||
tooltipCallback: this._getTooltip.bind(this),
|
||||
});
|
||||
protected _refTimeline: Ref<HTMLElement> = createRef();
|
||||
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.
|
||||
* @param source The FrigateBrowseMediaSource in question.
|
||||
@@ -446,12 +545,206 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
></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 {
|
||||
if (properties.what === 'item') {
|
||||
if (properties.what && ['item', 'background'].includes(properties.what)) {
|
||||
// Prevent interaction with items on the timeline from activating card
|
||||
// wide actions.
|
||||
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;
|
||||
}
|
||||
if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
|
||||
this._events
|
||||
.fetchEventsIfNecessary(
|
||||
this._data
|
||||
.fetchIfNecessary(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.timelineConfig.media,
|
||||
properties.start,
|
||||
properties.end,
|
||||
this.timelineConfig.show_recordings,
|
||||
)
|
||||
.then(() => {
|
||||
if (this._timeline) {
|
||||
@@ -507,7 +801,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
const childIndex = data.items.length
|
||||
? this.view.target.children.findIndex(
|
||||
(child) => child.frigate?.event.id === data.items[0],
|
||||
(child) => child.frigate?.event?.id === data.items[0],
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -560,8 +854,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
const selected = this._timeline.getSelection();
|
||||
let childIndex = -1;
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
this._events.dataset.get({ order: sortEvent }).forEach((item) => {
|
||||
if (item.source) {
|
||||
this._data.dataset.get({ order: sortEvent }).forEach((item) => {
|
||||
if (item.event && item.source) {
|
||||
children.push(item.source);
|
||||
if (selected.includes(item.event.id)) {
|
||||
childIndex = children.length - 1;
|
||||
@@ -572,9 +866,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = createEventParentForChildren('Timeline events', children);
|
||||
return {
|
||||
target: target,
|
||||
target: createEventParentForChildren('Timeline events', children),
|
||||
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
|
||||
// different object types together (e.g. person and car).
|
||||
return (
|
||||
[first.type, second.type].every((type) => type !== 'background') &&
|
||||
first.type === second.type &&
|
||||
!!first.id &&
|
||||
first.id !== this.view?.media?.frigate?.event?.id &&
|
||||
!!second.id &&
|
||||
second.id != this.view?.media?.frigate?.event?.id &&
|
||||
(<FrigateCardTimelineItem>first).event.label ===
|
||||
(<FrigateCardTimelineItem>second).event.label
|
||||
(<FrigateCardTimelineItem>first).event?.label ===
|
||||
(<FrigateCardTimelineItem>second).event?.label
|
||||
);
|
||||
},
|
||||
}
|
||||
@@ -750,13 +1045,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
* Update the timeline from the view object.
|
||||
*/
|
||||
protected async _updateTimelineFromView(): Promise<void> {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameras ||
|
||||
!this.view ||
|
||||
!this._timeline ||
|
||||
!this.timelineConfig
|
||||
) {
|
||||
if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -765,15 +1054,20 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
? this._getStartEndFromEvent(event)
|
||||
: this._getStartEnd();
|
||||
|
||||
await this._events.fetchEventsIfNecessary(
|
||||
await this._data.fetchIfNecessary(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.timelineConfig.media,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
this.timelineConfig.show_recordings,
|
||||
);
|
||||
|
||||
if (!this._timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._timeline.setSelection(event ? [event.id] : [], {
|
||||
focus: false,
|
||||
animation: {
|
||||
@@ -807,9 +1101,9 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||
// update the dataset to ensure the newly selected item cannot be included
|
||||
// in a cluster.
|
||||
const item = this._events.dataset.get(event.id);
|
||||
const item = this._data.dataset.get(event.id);
|
||||
if (item) {
|
||||
this._events.dataset.updateOnly(item);
|
||||
this._data.dataset.updateOnly(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -825,7 +1119,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
// -> New view dispatched (to load thumbnails into outer carousel).
|
||||
// -> New view received ... [loop]
|
||||
const currentContext = this.view.context as TimelineViewContext | null;
|
||||
if (currentContext?.dateFetch !== this._events.lastFetchDate) {
|
||||
if (currentContext?.dateFetch !== this._data.lastFetchDate) {
|
||||
const thumbnails = this._generateThumbnails();
|
||||
this.view
|
||||
?.evolve({
|
||||
@@ -851,8 +1145,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
} else if (currentContext?.window) {
|
||||
newContext.window = currentContext.window;
|
||||
}
|
||||
if (this._events.lastFetchDate) {
|
||||
newContext.dateFetch = this._events.lastFetchDate;
|
||||
if (this._data.lastFetchDate) {
|
||||
newContext.dateFetch = this._data.lastFetchDate;
|
||||
}
|
||||
return newContext || null;
|
||||
}
|
||||
@@ -865,7 +1159,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('cameras')) {
|
||||
this._events.clear();
|
||||
this._data.clear();
|
||||
this._timeline?.destroy();
|
||||
this._timeline = undefined;
|
||||
}
|
||||
@@ -888,14 +1182,14 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
this._timeline = new Timeline(
|
||||
this._refTimeline.value,
|
||||
this._events.dataset,
|
||||
this._data.dataset,
|
||||
groups,
|
||||
options,
|
||||
);
|
||||
this._timeline.on('select', this._timelineSelectHandler.bind(this));
|
||||
this._timeline.on('rangechanged', this._timelineRangeHandler.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,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateCardMediaPlayer,
|
||||
MediaShowInfo,
|
||||
TransitionEffect,
|
||||
ViewerConfig
|
||||
@@ -36,8 +37,8 @@ import {
|
||||
multipleBrowseMediaQueryMerged,
|
||||
overrideMultiBrowseMediaQueryParameters
|
||||
} from '../utils/ha/browse-media.js';
|
||||
import { createMediaShowInfo } from '../utils/media-info.js';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||
import { createMediaShowInfo } from '../utils/media-info.js';
|
||||
import { View } from '../view.js';
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.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')
|
||||
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
@property({ attribute: false })
|
||||
@@ -168,11 +171,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
++i
|
||||
) {
|
||||
if (isTrueMedia(target.children[i])) {
|
||||
await resolveMedia(
|
||||
this.hass,
|
||||
target.children[i],
|
||||
this.resolvedMediaCache,
|
||||
);
|
||||
await resolveMedia(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.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
@@ -291,7 +307,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
}),
|
||||
}),
|
||||
AutoMediaPlugin({
|
||||
playerSelector: 'frigate-card-ha-hls-player',
|
||||
playerSelector: FRIGATE_CARD_HLS_SELECTOR,
|
||||
...(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;
|
||||
|
||||
// Frigate >= 0.9.0+ clips.
|
||||
const hls_player = slide.querySelector(
|
||||
'frigate-card-ha-hls-player',
|
||||
) as HTMLElement & { url: string };
|
||||
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
if (img) {
|
||||
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(
|
||||
mediaToRender: FrigateBrowseMediaSource,
|
||||
slideIndex: number,
|
||||
|
||||
@@ -118,6 +118,7 @@ export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as
|
||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_TIMELINE}.clustering_threshold` 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 =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_TIMELINE_MEDIA,
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_VIEW_CAMERA_SELECT,
|
||||
CONF_VIEW_DARK_MODE,
|
||||
@@ -1223,6 +1224,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_TIMELINE_MEDIA,
|
||||
this._timelineMediaTypes,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
defaults.timeline.show_recordings,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||
this._thumbnailModes,
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"no_clips": "No clips",
|
||||
"no_snapshot": "No recent snapshot",
|
||||
"no_clip": "No recent clip",
|
||||
"live": "Live"
|
||||
"live": "Live",
|
||||
"recordings": "Recordings"
|
||||
},
|
||||
"config": {
|
||||
"cameras": {
|
||||
@@ -109,8 +110,8 @@
|
||||
"thumbnails": {
|
||||
"mode": "Event Viewer thumbnails mode",
|
||||
"size": "Event Viewer thumbnails size in pixels",
|
||||
"show_details": "Show event details with thumbnails",
|
||||
"show_controls": "Show event controls with thumbnails",
|
||||
"show_details": "Show details with thumbnails",
|
||||
"show_controls": "Show controls with thumbnails",
|
||||
"modes": {
|
||||
"below": "Thumbnails below the media",
|
||||
"above": "Thumbnails above the media",
|
||||
@@ -229,6 +230,7 @@
|
||||
"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)",
|
||||
"media": "The media the timeline displays",
|
||||
"show_recordings": "Show recordings",
|
||||
"medias": {
|
||||
"all": "All media types",
|
||||
"clips": "Clips",
|
||||
@@ -292,13 +294,20 @@
|
||||
"in_progress": "In Progress",
|
||||
"score": "Score"
|
||||
},
|
||||
"recording": {
|
||||
"events": "Events"
|
||||
},
|
||||
"thumbnail": {
|
||||
"retain_indefinitely": "Event will be indefinitely retained",
|
||||
"timeline": "See event in timeline"
|
||||
"timeline": "See event in timeline",
|
||||
"no_thumbnail": "No thumbnail available"
|
||||
},
|
||||
"error": {
|
||||
"undecodable_response": "Could not decode 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",
|
||||
"failed_response": "Failed to receive response from Home Assistant for request",
|
||||
"failed_sign": "Could not sign Home Assistant URL",
|
||||
"invalid_keys": "Invalid keys",
|
||||
"unknown": "Unknown error",
|
||||
"troubleshooting": "Check troubleshooting",
|
||||
|
||||
@@ -77,6 +77,13 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
this._player?.unmute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video (unsupported).
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
this._player?.seek(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @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:
|
||||
// - 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:
|
||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
// Ensure error messages are selectable.
|
||||
user-select: text;
|
||||
// Safari only has prefixed support.
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
div.wrapper {
|
||||
@@ -20,6 +25,7 @@ div.message {
|
||||
div.message div.contents {
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div.message div.icon {
|
||||
@@ -37,4 +43,6 @@ div.message div.icon {
|
||||
|
||||
.message pre {
|
||||
margin-top: 20px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
box-shadow: 0px 0px 20px 5px black;
|
||||
transition: all 0.2s ease-out;
|
||||
opacity: 0.8;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
.controls.thumbnails:hover {
|
||||
opacity: 1 !important;
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
column-gap: 5%;
|
||||
}
|
||||
|
||||
div.right, div.left {
|
||||
div.right,
|
||||
div.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
div.right {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.left {
|
||||
flex: 1;
|
||||
@@ -36,6 +40,7 @@ span.heading {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.larger {
|
||||
div.larger,
|
||||
span.larger {
|
||||
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);
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
|
||||
// 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%;
|
||||
|
||||
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 {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
ha-icon.favorite {
|
||||
position: absolute;
|
||||
color: gold;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
ha-icon.timeline {
|
||||
position: absolute;
|
||||
color: var(--primary-color);
|
||||
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);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
.vis-item.vis-background {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.vis-item:hover {
|
||||
// Float icons upwards when the user hovers over them.
|
||||
z-index: 2;
|
||||
.vis-item:not(.vis-background) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.vis-item.vis-background, .vis-labelset, .vis-time-axis {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.vis-item:active {
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
.vis-item.vis-box {
|
||||
|
||||
+49
-9
@@ -83,7 +83,14 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
|
||||
export type AutoUnmuteCondition = 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)
|
||||
@@ -400,7 +407,10 @@ const cameraConfigSchema = z
|
||||
|
||||
trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion),
|
||||
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);
|
||||
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
||||
@@ -508,7 +518,7 @@ const viewConfigDefault = {
|
||||
scan: {
|
||||
enabled: false,
|
||||
show_trigger_status: true,
|
||||
}
|
||||
},
|
||||
};
|
||||
const viewConfigSchema = z
|
||||
.object({
|
||||
@@ -525,10 +535,14 @@ const viewConfigSchema = z
|
||||
update_entities: z.string().array().optional(),
|
||||
render_entities: z.string().array().optional(),
|
||||
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
|
||||
scan: z.object({
|
||||
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
|
||||
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
|
||||
}).default(viewConfigDefault.scan)
|
||||
scan: z
|
||||
.object({
|
||||
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
|
||||
show_trigger_status: z
|
||||
.boolean()
|
||||
.default(viewConfigDefault.scan.show_trigger_status),
|
||||
})
|
||||
.default(viewConfigDefault.scan),
|
||||
})
|
||||
.merge(actionsSchema)
|
||||
.default(viewConfigDefault);
|
||||
@@ -970,6 +984,7 @@ const timelineConfigDefault = {
|
||||
clustering_threshold: 3,
|
||||
media: 'all' as const,
|
||||
window_seconds: 60 * 60,
|
||||
show_recordings: true,
|
||||
controls: {
|
||||
thumbnails: {
|
||||
mode: 'left' as const,
|
||||
@@ -995,6 +1010,7 @@ const timelineConfigSchema = z
|
||||
.max(24 * 60 * 60)
|
||||
.optional()
|
||||
.default(timelineConfigDefault.window_seconds),
|
||||
show_recordings: z.boolean().optional().default(timelineConfigDefault.show_recordings),
|
||||
controls: z
|
||||
.object({
|
||||
thumbnails: thumbnailsControlSchema
|
||||
@@ -1130,6 +1146,15 @@ export interface BrowseMediaQueryParameters {
|
||||
cameraID?: string;
|
||||
}
|
||||
|
||||
export interface BrowseRecordingQueryParameters {
|
||||
clientId: string;
|
||||
cameraName: string;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
export interface BrowseMediaNeighbors {
|
||||
previous: FrigateBrowseMediaSource | null;
|
||||
previousIndex: number | null;
|
||||
@@ -1165,6 +1190,7 @@ export interface FrigateCardMediaPlayer {
|
||||
pause(): void;
|
||||
mute(): void;
|
||||
unmute(): void;
|
||||
seek(seconds: number): void;
|
||||
}
|
||||
|
||||
export interface CardHelpers {
|
||||
@@ -1180,7 +1206,9 @@ export interface CardHelpers {
|
||||
*/
|
||||
|
||||
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_VIDEO = 'video' as const;
|
||||
|
||||
// Recursive type, cannot use type interference:
|
||||
// See: https://github.com/colinhacks/zod#recursive-types
|
||||
@@ -1212,10 +1240,22 @@ export interface FrigateEvent {
|
||||
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 {
|
||||
children?: FrigateBrowseMediaSource[] | null;
|
||||
frigate?: {
|
||||
event: FrigateEvent;
|
||||
event?: FrigateEvent;
|
||||
recording?: FrigateRecording;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1274,7 +1314,7 @@ export type Entity = z.infer<typeof entitySchema>;
|
||||
export const extendedEntitySchema = entitySchema.extend({
|
||||
// Extended entity results.
|
||||
unique_id: z.string().optional(),
|
||||
})
|
||||
});
|
||||
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
|
||||
|
||||
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 {
|
||||
dispatchErrorMessageEvent,
|
||||
dispatchFrigateCardErrorEvent,
|
||||
dispatchMessageEvent
|
||||
} from '../../components/message.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
BrowseRecordingQueryParameters,
|
||||
CameraConfig,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateBrowseMediaSourceSchema, FrigateEvent, MEDIA_CLASS_PLAYLIST,
|
||||
MEDIA_TYPE_PLAYLIST
|
||||
frigateBrowseMediaSourceSchema,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
FrigateRecording,
|
||||
MEDIA_CLASS_PLAYLIST,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
MEDIA_TYPE_PLAYLIST,
|
||||
MEDIA_TYPE_VIDEO
|
||||
} from '../../types.js';
|
||||
import { View } from '../../view.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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 {
|
||||
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(element, (e as Error).message);
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
const childIndex = getFirstTrueMediaChildIndex(parent);
|
||||
if (!parent || !parent.children || childIndex == null) {
|
||||
@@ -376,7 +384,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
|
||||
try {
|
||||
parent = await browseMedia(hass, child.media_content_id);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(element, (e as Error).message);
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
|
||||
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
|
||||
* 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`;
|
||||
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('/');
|
||||
};
|
||||
|
||||
+81
-11
@@ -4,7 +4,9 @@ import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { ZodSchema } from 'zod';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
CardHelpers, ExtendedHomeAssistant,
|
||||
CardHelpers,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardError,
|
||||
SignedPath,
|
||||
signedPathSchema,
|
||||
StateParameters
|
||||
@@ -36,12 +38,16 @@ export async function homeAssistantWSRequest<T>(
|
||||
const parseResult = schema.safeParse(response);
|
||||
if (!parseResult.success) {
|
||||
const keys = getParseErrorKeys<T>(parseResult.error);
|
||||
const error_message =
|
||||
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
|
||||
localize('error.invalid_keys') +
|
||||
`: '${keys}'`;
|
||||
console.warn(error_message);
|
||||
throw new Error(error_message);
|
||||
const error_message = localize('error.invalid_response');
|
||||
console.warn(
|
||||
`${error_message}: ${JSON.stringify(request)}. ${localize(
|
||||
'error.invalid_keys',
|
||||
)}: ${keys}`,
|
||||
);
|
||||
throw new FrigateCardError(error_message, {
|
||||
request: request,
|
||||
invalid_keys: keys,
|
||||
});
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
@@ -74,6 +80,70 @@ export async function homeAssistantSignPath(
|
||||
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 {
|
||||
entity: string;
|
||||
oldState?: HassEntity;
|
||||
@@ -308,9 +378,9 @@ export const isTriggeredState = (state?: HassEntity): boolean => {
|
||||
|
||||
/**
|
||||
* Get entities from the HASS object.
|
||||
* @param hass
|
||||
* @param domain
|
||||
* @returns
|
||||
* @param hass
|
||||
* @param domain
|
||||
* @returns A list of entities ids.
|
||||
*/
|
||||
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
|
||||
if (!hass) {
|
||||
@@ -321,4 +391,4 @@ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): strin
|
||||
);
|
||||
entities.sort();
|
||||
return entities;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user