Initial code for major engine refactor.
This commit is contained in:
+1
-1
@@ -37,7 +37,7 @@
|
|||||||
"side-drawer": "^3.1.0",
|
"side-drawer": "^3.1.0",
|
||||||
"ts-toolbelt": "^9.6.0",
|
"ts-toolbelt": "^9.6.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"vis-data": "^7.1.3",
|
"vis-data": "^7.1.4",
|
||||||
"vis-timeline": "^7.7.0",
|
"vis-timeline": "^7.7.0",
|
||||||
"vis-util": "^5.0.2",
|
"vis-util": "^5.0.2",
|
||||||
"xss": "^1.0.14",
|
"xss": "^1.0.14",
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ export class CardConditionManager {
|
|||||||
* Trigger the callback.
|
* Trigger the callback.
|
||||||
* @param _ Ignored parameter.
|
* @param _ Ignored parameter.
|
||||||
*/
|
*/
|
||||||
protected _triggerChange(_): void {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
protected _triggerChange(_: MediaQueryListEvent): void {
|
||||||
this._callback();
|
this._callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-76
@@ -53,8 +53,6 @@ import {
|
|||||||
FrigateCardView,
|
FrigateCardView,
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
MEDIA_TYPE_IMAGE,
|
|
||||||
MEDIA_TYPE_VIDEO,
|
|
||||||
MESSAGE_TYPE_PRIORITIES,
|
MESSAGE_TYPE_PRIORITIES,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
Message,
|
Message,
|
||||||
@@ -80,7 +78,6 @@ import {
|
|||||||
isTriggeredState,
|
isTriggeredState,
|
||||||
sideLoadHomeAssistantElements,
|
sideLoadHomeAssistantElements,
|
||||||
} from './utils/ha';
|
} from './utils/ha';
|
||||||
import { getEventID } from './utils/ha/browse-media.js';
|
|
||||||
import { DeviceList, getAllDevices } from './utils/ha/device-registry.js';
|
import { DeviceList, getAllDevices } from './utils/ha/device-registry.js';
|
||||||
import {
|
import {
|
||||||
ExtendedEntityCache,
|
ExtendedEntityCache,
|
||||||
@@ -94,8 +91,10 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
|||||||
import { View } from './view.js';
|
import { View } from './view.js';
|
||||||
import pkg from '../package.json';
|
import pkg from '../package.json';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { DataManager } from './utils/data-manager.js';
|
import { DataManager } from './utils/data/data-manager.js';
|
||||||
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
|
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
|
||||||
|
import { DataManagerEngineFactory } from './utils/data/data-manager-engine-factory.js';
|
||||||
|
import { RequestCache } from './utils/data/data-manager-cache.js';
|
||||||
|
|
||||||
/** A note on media callbacks:
|
/** A note on media callbacks:
|
||||||
*
|
*
|
||||||
@@ -144,6 +143,8 @@ console.info(
|
|||||||
documentationURL: REPO_URL,
|
documentationURL: REPO_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type InitializedType = 'initialized' | 'initializing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main FrigateCard class.
|
* Main FrigateCard class.
|
||||||
*/
|
*/
|
||||||
@@ -204,7 +205,6 @@ export class FrigateCard extends LitElement {
|
|||||||
// A cache of resolved media URLs/mimetypes for use in the whole card.
|
// A cache of resolved media URLs/mimetypes for use in the whole card.
|
||||||
protected _resolvedMediaCache = new ResolvedMediaCache();
|
protected _resolvedMediaCache = new ResolvedMediaCache();
|
||||||
|
|
||||||
// Shared timeline data manager (for main timeline view and mini-timelines).
|
|
||||||
protected _dataManager?: DataManager;
|
protected _dataManager?: DataManager;
|
||||||
|
|
||||||
// The mouse handler may be called continually, throttle it to at most once
|
// The mouse handler may be called continually, throttle it to at most once
|
||||||
@@ -212,8 +212,7 @@ export class FrigateCard extends LitElement {
|
|||||||
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
||||||
|
|
||||||
// Whether the card has been successfully initialized.
|
// Whether the card has been successfully initialized.
|
||||||
protected _loadedHAElements = false;
|
protected _initialized?: InitializedType;
|
||||||
protected _loadedLanguages = false;
|
|
||||||
|
|
||||||
protected _triggers: Map<string, Date> = new Map();
|
protected _triggers: Map<string, Date> = new Map();
|
||||||
protected _untriggerTimerID: number | null = null;
|
protected _untriggerTimerID: number | null = null;
|
||||||
@@ -530,7 +529,8 @@ export class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!this._isBeingCasted() &&
|
!this._isBeingCasted() &&
|
||||||
(this._view?.isViewerView() || (this._view?.is('timeline') && !!this._view?.media))
|
(this._view?.isViewerView() ||
|
||||||
|
(this._view?.is('timeline') && !!this._view?.queryResults?.hasSelectedResult()))
|
||||||
) {
|
) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: 'mdi:download',
|
icon: 'mdi:download',
|
||||||
@@ -1016,6 +1016,7 @@ export class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _changeView(args?: { view?: View; resetMessage?: boolean }): void {
|
protected _changeView(args?: { view?: View; resetMessage?: boolean }): void {
|
||||||
|
console.debug(`Frigate Card view change: `, args?.view ?? '[default]');
|
||||||
const changeView = (view: View): void => {
|
const changeView = (view: View): void => {
|
||||||
if (View.isMediaChange(this._view, view)) {
|
if (View.isMediaChange(this._view, view)) {
|
||||||
this._currentMediaLoadedInfo = null;
|
this._currentMediaLoadedInfo = null;
|
||||||
@@ -1099,18 +1100,12 @@ export class FrigateCard extends LitElement {
|
|||||||
* Called before each update.
|
* Called before each update.
|
||||||
*/
|
*/
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
// Side load the necessary elements if not already initialized (do not need
|
|
||||||
// to block for the loading to complete).
|
|
||||||
if (!this._loadedHAElements) {
|
|
||||||
sideLoadHomeAssistantElements().then((success) => {
|
|
||||||
if (success) {
|
|
||||||
this._loadedHAElements = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) {
|
if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) {
|
||||||
this._dataManager = new DataManager(this._cameras);
|
this._dataManager = new DataManager(
|
||||||
|
new DataManagerEngineFactory(),
|
||||||
|
this._cameras,
|
||||||
|
new RequestCache(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('_cardWideConfig')) {
|
if (changedProps.has('_cardWideConfig')) {
|
||||||
@@ -1237,6 +1232,12 @@ export class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the card.
|
||||||
|
*/
|
||||||
|
protected async _initialize(): Promise<void> {
|
||||||
|
await Promise.all([sideLoadHomeAssistantElements(), loadLanguages()]);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Determine whether the element should be updated.
|
* Determine whether the element should be updated.
|
||||||
* @param changedProps The changed properties if any.
|
* @param changedProps The changed properties if any.
|
||||||
@@ -1244,11 +1245,13 @@ export class FrigateCard extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||||
// Load the relevant languages. Cannot do anything until then.
|
// Load the relevant languages. Cannot do anything until then.
|
||||||
if (!this._loadedLanguages) {
|
if (this._initialized !== 'initialized') {
|
||||||
loadLanguages().then(() => {
|
if (this._initialized !== 'initializing') {
|
||||||
this._loadedLanguages = true;
|
this._initialize().then(() => {
|
||||||
this.requestUpdate();
|
this._initialized = 'initialized';
|
||||||
});
|
this.requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1318,12 +1321,9 @@ export class FrigateCard extends LitElement {
|
|||||||
// Should not occur.
|
// Should not occur.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const media = this._view.queryResults?.getSelectedResult();
|
||||||
|
|
||||||
if (
|
if (!media) {
|
||||||
!this._view.media ||
|
|
||||||
(this._view.media.media_content_type !== MEDIA_TYPE_VIDEO &&
|
|
||||||
this._view.media.media_content_type !== MEDIA_TYPE_IMAGE)
|
|
||||||
) {
|
|
||||||
this._setMessageAndUpdate({
|
this._setMessageAndUpdate({
|
||||||
message: localize('error.download_no_media'),
|
message: localize('error.download_no_media'),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -1336,35 +1336,8 @@ export class FrigateCard extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let path: string;
|
const path = this._dataManager?.getMediaDownloadPath(media);
|
||||||
if (this._view.media.frigate?.event) {
|
if (!path) {
|
||||||
const event_id = getEventID(this._view.media);
|
|
||||||
if (!event_id) {
|
|
||||||
this._setMessageAndUpdate({
|
|
||||||
message: localize('error.download_no_event_id'),
|
|
||||||
type: 'error',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
path =
|
|
||||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
|
||||||
`/notifications/${event_id}/` +
|
|
||||||
`${
|
|
||||||
this._view.media.media_content_type === MEDIA_TYPE_VIDEO
|
|
||||||
? 'clip.mp4'
|
|
||||||
: 'snapshot.jpg'
|
|
||||||
}` +
|
|
||||||
`?download=true`;
|
|
||||||
} else if (this._view.media.frigate?.recording) {
|
|
||||||
const recording = this._view.media.frigate.recording;
|
|
||||||
path =
|
|
||||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
|
||||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
|
||||||
`/start/${recording.start_time}` +
|
|
||||||
`/end/${recording.end_time}` +
|
|
||||||
`?download=true`;
|
|
||||||
} else {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1412,30 +1385,35 @@ export class FrigateCard extends LitElement {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void {
|
protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void {
|
||||||
if (!['play', 'stop'].includes(action)) {
|
if (!['play', 'stop'].includes(action) || !this._view) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let media_content_id: string;
|
let media_content_id: string | null = null;
|
||||||
let media_content_type: string;
|
let media_content_type: string | null = null;
|
||||||
const extra = {};
|
let title: string | null = null;
|
||||||
const cameraConfig = this._getSelectedCameraConfig();
|
let thumbnail: string | null = null;
|
||||||
const cameraEntity = cameraConfig?.camera_entity ?? null;
|
|
||||||
|
|
||||||
if (this._view?.isViewerView() && this._view.media) {
|
const cameraConfig = this._getSelectedCameraConfig();
|
||||||
media_content_id = this._view.media.media_content_id;
|
if (!cameraConfig) {
|
||||||
media_content_type = this._view.media.media_content_type;
|
return;
|
||||||
extra['thumb'] = this._view.media.thumbnail;
|
}
|
||||||
extra['title'] = this._view.media.title;
|
const cameraEntity = cameraConfig.camera_entity ?? null;
|
||||||
|
const media = this._view.queryResults?.getSelectedResult();
|
||||||
|
|
||||||
|
if (this._view.isViewerView() && media && this._cameras) {
|
||||||
|
media_content_id = media.getContentID(cameraConfig);
|
||||||
|
media_content_type = media.getContentType();
|
||||||
|
title = media.getTitle(cameraConfig);
|
||||||
|
thumbnail = media.getThumbnail(cameraConfig);
|
||||||
} else if (this._view?.is('live') && cameraEntity) {
|
} else if (this._view?.is('live') && cameraEntity) {
|
||||||
if (this._hass?.states && cameraEntity in this._hass.states) {
|
|
||||||
extra['thumb'] =
|
|
||||||
this._hass.states[cameraEntity].attributes.entity_picture ?? null;
|
|
||||||
}
|
|
||||||
extra['title'] = getCameraTitle(this._hass, cameraConfig);
|
|
||||||
media_content_id = `media-source://camera/${cameraEntity}`;
|
media_content_id = `media-source://camera/${cameraEntity}`;
|
||||||
media_content_type = 'application/vnd.apple.mpegurl';
|
media_content_type = 'application/vnd.apple.mpegurl';
|
||||||
} else {
|
title = getCameraTitle(this._hass, cameraConfig);
|
||||||
|
thumbnail = this._hass?.states[cameraEntity]?.attributes?.entity_picture ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!media_content_id || !media_content_type) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,7 +1422,10 @@ export class FrigateCard extends LitElement {
|
|||||||
entity_id: mediaPlayer,
|
entity_id: mediaPlayer,
|
||||||
media_content_id: media_content_id,
|
media_content_id: media_content_id,
|
||||||
media_content_type: media_content_type,
|
media_content_type: media_content_type,
|
||||||
extra: extra,
|
extra: {
|
||||||
|
...(title && { title: title }),
|
||||||
|
...(thumbnail && { thumb: thumbnail }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else if (action === 'stop') {
|
} else if (action === 'stop') {
|
||||||
this._hass?.callService('media_player', 'media_stop', {
|
this._hass?.callService('media_player', 'media_stop', {
|
||||||
|
|||||||
+20
-36
@@ -41,15 +41,12 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public carouselPlugins?: EmblaCarouselPlugins;
|
public carouselPlugins?: EmblaCarouselPlugins;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public selected = 0;
|
||||||
|
|
||||||
@property({ attribute: true })
|
@property({ attribute: true })
|
||||||
public transitionEffect?: TransitionEffect;
|
public transitionEffect?: TransitionEffect;
|
||||||
|
|
||||||
// An override to the startIndex, used to preserve the current carousel
|
|
||||||
// position after the carousel is destroyed (so it can be restored if
|
|
||||||
// recreated).
|
|
||||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/775
|
|
||||||
protected _savedStartIndex: number | null = null;
|
|
||||||
|
|
||||||
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||||
|
|
||||||
protected _carousel?: EmblaCarouselType;
|
protected _carousel?: EmblaCarouselType;
|
||||||
@@ -81,7 +78,7 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
// Destroy the carousel when the component is disconnected, which forces the
|
// Destroy the carousel when the component is disconnected, which forces the
|
||||||
// plugins (which may have registered event handlers) to also be destroyed.
|
// plugins (which may have registered event handlers) to also be destroyed.
|
||||||
// The carousel will automatically reconstruct if the component is re-rendered.
|
// The carousel will automatically reconstruct if the component is re-rendered.
|
||||||
this._destroyCarousel({ savePosition: true });
|
this._destroyCarousel();
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +93,7 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
'carouselPlugins',
|
'carouselPlugins',
|
||||||
] as const;
|
] as const;
|
||||||
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
||||||
this._destroyCarousel({ savePosition: true });
|
this._destroyCarousel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,31 +102,21 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
* @param index Slide number.
|
* @param index Slide number.
|
||||||
*/
|
*/
|
||||||
public carouselScrollTo(index: number): void {
|
public carouselScrollTo(index: number): void {
|
||||||
const scroll = () =>
|
this.selected = index;
|
||||||
this._carousel?.scrollTo(index, this.transitionEffect === 'none');
|
|
||||||
// This ensures scrolling can work on initial render when the carousel may
|
|
||||||
// not yet exist.
|
|
||||||
if (this._carousel) {
|
|
||||||
scroll();
|
|
||||||
} else {
|
|
||||||
this.updateComplete.then(() => {
|
|
||||||
scroll();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll to the previous slide.
|
* Scroll to the previous slide.
|
||||||
*/
|
*/
|
||||||
public carouselScrollPrevious(): void {
|
public carouselScrollPrevious(): void {
|
||||||
this._carousel?.scrollPrev(this.transitionEffect === 'none');
|
this.selected = Math.max(0, this.selected - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll to the next slide.
|
* Scroll to the next slide.
|
||||||
*/
|
*/
|
||||||
public carouselScrollNext(): void {
|
public carouselScrollNext(): void {
|
||||||
this._carousel?.scrollNext(this.transitionEffect === 'none');
|
this.selected = this.selected + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,11 +161,10 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
this._carousel?.reInit({ ...options });
|
this._carousel?.reInit({ ...options });
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
const selected = this.getCarouselSelected();
|
|
||||||
|
|
||||||
carouselReInit({
|
carouselReInit({
|
||||||
...(selected && { startIndex: selected.index }),
|
startIndex: this.selected,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +197,10 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
if (!this._carousel) {
|
if (!this._carousel) {
|
||||||
this._initCarousel();
|
this._initCarousel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changedProperties.has('selected')) {
|
||||||
|
this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,9 +208,7 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
* @param options If `savePosition` is set the existing carousel position
|
* @param options If `savePosition` is set the existing carousel position
|
||||||
* will be saved so it can be restored if the carousel is recreated.
|
* will be saved so it can be restored if the carousel is recreated.
|
||||||
*/
|
*/
|
||||||
protected _destroyCarousel(options?: { savePosition: boolean }): void {
|
protected _destroyCarousel(): void {
|
||||||
this._savedStartIndex =
|
|
||||||
(options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null;
|
|
||||||
if (this._carousel) {
|
if (this._carousel) {
|
||||||
this._carousel.destroy();
|
this._carousel.destroy();
|
||||||
}
|
}
|
||||||
@@ -248,8 +236,8 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
{
|
{
|
||||||
axis: this.direction == 'horizontal' ? 'x' : 'y',
|
axis: this.direction == 'horizontal' ? 'x' : 'y',
|
||||||
speed: 20,
|
speed: 20,
|
||||||
|
startIndex: this.selected,
|
||||||
...this.carouselOptions,
|
...this.carouselOptions,
|
||||||
...(this._savedStartIndex !== null && { startIndex: this._savedStartIndex }),
|
|
||||||
},
|
},
|
||||||
this.carouselPlugins,
|
this.carouselPlugins,
|
||||||
);
|
);
|
||||||
@@ -262,7 +250,7 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
// Make sure every select causes a refresh to allow for re-paint of the
|
// Make sure every select causes a refresh to allow for re-paint of the
|
||||||
// next/previous controls.
|
// next/previous controls.
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
};
|
||||||
|
|
||||||
this._carousel.on('init', selectSlide);
|
this._carousel.on('init', selectSlide);
|
||||||
this._carousel.on('select', selectSlide);
|
this._carousel.on('select', selectSlide);
|
||||||
@@ -294,18 +282,14 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
protected _slotChanged(): void {
|
protected _slotChanged(): void {
|
||||||
// Cannot just re-init, because the slide elements themselves may have
|
// Cannot just re-init, because the slide elements themselves may have
|
||||||
// changed, and only a carousel init can pass in new (slotted) children. If
|
// changed, and only a carousel init can pass in new (slotted) children. If
|
||||||
// the slides themselves change, any position the user has set is assumed to
|
this._destroyCarousel();
|
||||||
// be abandoned and so the startIndex is reset to whatever the carousel was
|
|
||||||
// originally configured with.
|
|
||||||
this._destroyCarousel({ savePosition: false });
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
|
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
|
||||||
const currentSlide = (this._carousel?.selectedScrollSnap() ?? this.carouselOptions?.startIndex) ?? 0;
|
const showPrevious = this.carouselOptions?.loop || this.selected > 0;
|
||||||
const showPrevious = this.carouselOptions?.loop || currentSlide > 0;
|
const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length;
|
||||||
const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
|
|
||||||
|
|
||||||
return html` <div class="embla">
|
return html` <div class="embla">
|
||||||
${showPrevious ? html`<slot name="previous"></slot>` : ``}
|
${showPrevious ? html`<slot name="previous"></slot>` : ``}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export class FrigateCardDrawer extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
|
<slot ${ref(this._refSlot)} @slotchange=${() => this._slotChanged()}></slot>
|
||||||
</side-drawer>
|
</side-drawer>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-238
@@ -19,12 +19,10 @@ import {
|
|||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import {
|
import {
|
||||||
fetchChildMediaAndDispatchViewChange,
|
|
||||||
fetchLatestMediaAndDispatchViewChange,
|
|
||||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
||||||
} from '../utils/ha/browse-media';
|
} from '../utils/ha/browse-media';
|
||||||
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
|
import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
|
||||||
import { DataManager } from '../utils/data-manager.js';
|
import { DataManager } from '../utils/data/data-manager.js';
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
import { renderProgressIndicator } from './message.js';
|
import { renderProgressIndicator } from './message.js';
|
||||||
import './thumbnail.js';
|
import './thumbnail.js';
|
||||||
@@ -66,63 +64,54 @@ export class FrigateCardGallery extends LitElement {
|
|||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const mediaType = this.view?.getMediaType();
|
// const mediaType = this.view?.getMediaType();
|
||||||
if (
|
// if (
|
||||||
!this.hass ||
|
// !this.hass ||
|
||||||
!this.view ||
|
// !this.view ||
|
||||||
!this.cameras ||
|
// !this.cameras ||
|
||||||
!this.view.isGalleryView() ||
|
// !this.view.isGalleryView() ||
|
||||||
!mediaType ||
|
// !mediaType ||
|
||||||
!this.dataManager
|
// !this.dataManager
|
||||||
) {
|
// ) {
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!this.view.target) {
|
// if (!this.view.query) {
|
||||||
if (mediaType === 'recordings') {
|
// if (mediaType === 'recordings') {
|
||||||
changeViewToRecentRecordingForCameraAndDependents(
|
// changeViewToRecentRecordingForCameraAndDependents(
|
||||||
this,
|
// this,
|
||||||
this.hass,
|
// this.hass,
|
||||||
this.dataManager,
|
// this.dataManager,
|
||||||
this.cameras,
|
// this.cameras,
|
||||||
this.view,
|
// this.view,
|
||||||
{
|
// {
|
||||||
targetView: 'recordings',
|
// targetView: 'recordings',
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
} else {
|
// } else {
|
||||||
const browseMediaQueryParameters =
|
// changeViewToRecentEventsForCameraAndDependents(
|
||||||
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
// this,
|
||||||
this,
|
// this.hass,
|
||||||
this.hass,
|
// this.dataManager,
|
||||||
this.cameras,
|
// this.cameras,
|
||||||
this.view.camera,
|
// this.view,
|
||||||
mediaType,
|
// {
|
||||||
);
|
// targetView: mediaType,
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||||
|
// }
|
||||||
|
|
||||||
if (!browseMediaQueryParameters) {
|
// return html`
|
||||||
return;
|
// <frigate-card-gallery-core
|
||||||
}
|
// .hass=${this.hass}
|
||||||
|
// .view=${this.view}
|
||||||
fetchLatestMediaAndDispatchViewChange(
|
// .galleryConfig=${this.galleryConfig}
|
||||||
this,
|
// .cameras=${this.cameras}
|
||||||
this.hass,
|
// >
|
||||||
this.view,
|
// </frigate-card-gallery-core>
|
||||||
browseMediaQueryParameters,
|
// `;
|
||||||
);
|
|
||||||
}
|
|
||||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<frigate-card-gallery-core
|
|
||||||
.hass=${this.hass}
|
|
||||||
.view=${this.view}
|
|
||||||
.galleryConfig=${this.galleryConfig}
|
|
||||||
.cameras=${this.cameras}
|
|
||||||
>
|
|
||||||
</frigate-card-gallery-core>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,203 +128,206 @@ export class FrigateCardGallery extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-gallery-core')
|
// @customElement('frigate-card-gallery-core')
|
||||||
export class FrigateCardGalleryCore extends LitElement {
|
// export class FrigateCardGalleryCore extends LitElement {
|
||||||
@property({ attribute: false })
|
// @property({ attribute: false })
|
||||||
public hass?: ExtendedHomeAssistant;
|
// public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
// @property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
// public view?: Readonly<View>;
|
||||||
|
|
||||||
@property({ attribute: false })
|
// @property({ attribute: false })
|
||||||
public galleryConfig?: GalleryConfig;
|
// public galleryConfig?: GalleryConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
// @property({ attribute: false })
|
||||||
public cameras?: Map<string, CameraConfig>;
|
// public cameras?: Map<string, CameraConfig>;
|
||||||
|
|
||||||
protected _resizeObserver: ResizeObserver;
|
// protected _resizeObserver: ResizeObserver;
|
||||||
|
|
||||||
constructor() {
|
// constructor() {
|
||||||
super();
|
// super();
|
||||||
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
// this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Component connected callback.
|
// * Component connected callback.
|
||||||
*/
|
// */
|
||||||
connectedCallback(): void {
|
// connectedCallback(): void {
|
||||||
super.connectedCallback();
|
// super.connectedCallback();
|
||||||
this._resizeObserver.observe(this);
|
// this._resizeObserver.observe(this);
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Component disconnected callback.
|
// * Component disconnected callback.
|
||||||
*/
|
// */
|
||||||
disconnectedCallback(): void {
|
// disconnectedCallback(): void {
|
||||||
this._resizeObserver.disconnect();
|
// this._resizeObserver.disconnect();
|
||||||
super.disconnectedCallback();
|
// super.disconnectedCallback();
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Set gallery columns.
|
// * Set gallery columns.
|
||||||
*/
|
// */
|
||||||
protected _setColumnCount(): void {
|
// protected _setColumnCount(): void {
|
||||||
const thumbnailSize =
|
// const thumbnailSize =
|
||||||
this.galleryConfig?.controls.thumbnails.size ??
|
// this.galleryConfig?.controls.thumbnails.size ??
|
||||||
frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
|
// frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
|
||||||
const columns = this.galleryConfig?.controls.thumbnails.show_details
|
// const columns = this.galleryConfig?.controls.thumbnails.show_details
|
||||||
? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
|
// ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
|
||||||
: Math.max(
|
// : Math.max(
|
||||||
1,
|
// 1,
|
||||||
Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX),
|
// Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX),
|
||||||
Math.ceil(this.clientWidth / thumbnailSize),
|
// Math.ceil(this.clientWidth / thumbnailSize),
|
||||||
);
|
// );
|
||||||
|
|
||||||
this.style.setProperty('--frigate-card-gallery-columns', String(columns));
|
// this.style.setProperty('--frigate-card-gallery-columns', String(columns));
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Handle gallery resize.
|
// * Handle gallery resize.
|
||||||
*/
|
// */
|
||||||
protected _resizeHandler(): void {
|
// protected _resizeHandler(): void {
|
||||||
this._setColumnCount();
|
// this._setColumnCount();
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Determine whether the back arrow should be displayed.
|
// * Determine whether the back arrow should be displayed.
|
||||||
* @returns `true` if the back arrow should be displayed, `false` otherwise.
|
// * @returns `true` if the back arrow should be displayed, `false` otherwise.
|
||||||
*/
|
// */
|
||||||
protected _showBackArrow(): boolean {
|
// protected _shouldShowBackArrow(): boolean {
|
||||||
return (
|
// return (
|
||||||
!!this.view?.context?.gallery?.previous &&
|
// !!this.view?.context?.gallery?.previous &&
|
||||||
!!this.view.context.gallery.previous.target &&
|
// !!this.view.context.gallery.previous.query &&
|
||||||
this.view.context.gallery.previous.view === this.view.view
|
// this.view.context.gallery.previous.view === this.view.view
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Called when an update will occur.
|
// * Called when an update will occur.
|
||||||
* @param changedProps The changed properties
|
// * @param changedProps The changed properties
|
||||||
*/
|
// */
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
// protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('galleryConfig')) {
|
// if (changedProps.has('galleryConfig')) {
|
||||||
if (this.galleryConfig?.controls.thumbnails.show_details) {
|
// if (this.galleryConfig?.controls.thumbnails.show_details) {
|
||||||
this.setAttribute('details', '');
|
// this.setAttribute('details', '');
|
||||||
} else {
|
// } else {
|
||||||
this.removeAttribute('details');
|
// this.removeAttribute('details');
|
||||||
}
|
// }
|
||||||
this._setColumnCount();
|
// this._setColumnCount();
|
||||||
if (this.galleryConfig?.controls.thumbnails.size) {
|
// if (this.galleryConfig?.controls.thumbnails.size) {
|
||||||
this.style.setProperty(
|
// this.style.setProperty(
|
||||||
'--frigate-card-thumbnail-size',
|
// '--frigate-card-thumbnail-size',
|
||||||
`${this.galleryConfig.controls.thumbnails.size}px`,
|
// `${this.galleryConfig.controls.thumbnails.size}px`,
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// // TODO: This is still going to show the gallery view (akin to HA media
|
||||||
* Master render method.
|
// // browser).
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
|
||||||
if (
|
|
||||||
!this.hass ||
|
|
||||||
!this.view ||
|
|
||||||
!this.view.target ||
|
|
||||||
!this.view.target.children ||
|
|
||||||
!this.view.isGalleryView() ||
|
|
||||||
!this.cameras
|
|
||||||
) {
|
|
||||||
return html``;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
// /**
|
||||||
${this._showBackArrow()
|
// * Master render method.
|
||||||
? html` <ha-card
|
// * @returns A rendered template.
|
||||||
@click=${(ev) => {
|
// */
|
||||||
if (this.view && this.view.context?.gallery?.previous) {
|
// protected render(): TemplateResult | void {
|
||||||
this.view.context.gallery.previous.dispatchChangeEvent(this);
|
// const results = this.view?.queryResults?.getResults();
|
||||||
}
|
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
|
||||||
}}
|
|
||||||
outlined=""
|
|
||||||
>
|
|
||||||
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
|
|
||||||
</ha-card>`
|
|
||||||
: ''}
|
|
||||||
${this.view.target.children.map(
|
|
||||||
(child, index) =>
|
|
||||||
html`
|
|
||||||
${child.can_expand
|
|
||||||
? html`
|
|
||||||
<ha-card
|
|
||||||
@click=${(ev) => {
|
|
||||||
if (this.hass && this.view) {
|
|
||||||
fetchChildMediaAndDispatchViewChange(
|
|
||||||
this,
|
|
||||||
this.hass,
|
|
||||||
this.view,
|
|
||||||
child,
|
|
||||||
{
|
|
||||||
gallery: {
|
|
||||||
previous: this.view,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
|
||||||
}}
|
|
||||||
outlined=""
|
|
||||||
>
|
|
||||||
<div>${child.title}</div>
|
|
||||||
</ha-card>
|
|
||||||
`
|
|
||||||
: html`<frigate-card-thumbnail
|
|
||||||
.view=${this.view}
|
|
||||||
.target=${this.view?.target ?? null}
|
|
||||||
.childIndex=${index}
|
|
||||||
.hass=${this.hass}
|
|
||||||
.cameraConfig=${child.frigate?.cameraID
|
|
||||||
? this.cameras?.get(child.frigate.cameraID)
|
|
||||||
: undefined}
|
|
||||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
|
||||||
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
|
||||||
.show_favorite_control}
|
|
||||||
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
|
|
||||||
.show_timeline_control}
|
|
||||||
@click=${(ev: Event) => {
|
|
||||||
if (this.view) {
|
|
||||||
const targetView = this.view.getViewerViewForGalleryView();
|
|
||||||
if (targetView) {
|
|
||||||
this.view
|
|
||||||
.evolve({
|
|
||||||
view: targetView,
|
|
||||||
childIndex: index,
|
|
||||||
})
|
|
||||||
.dispatchChangeEvent(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</frigate-card-thumbnail>`}
|
|
||||||
`,
|
|
||||||
)}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// if (
|
||||||
* Get styles.
|
// !results ||
|
||||||
*/
|
// !this.hass ||
|
||||||
static get styles(): CSSResultGroup {
|
// !this.view ||
|
||||||
return unsafeCSS(galleryStyle);
|
// !this.view.isGalleryView() ||
|
||||||
}
|
// !this.cameras
|
||||||
}
|
// ) {
|
||||||
|
// return html``;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return html`
|
||||||
|
// ${this._shouldShowBackArrow()
|
||||||
|
// ? html` <ha-card
|
||||||
|
// @click=${(ev) => {
|
||||||
|
// if (this.view && this.view.context?.gallery?.previous) {
|
||||||
|
// this.view.context.gallery.previous.dispatchChangeEvent(this);
|
||||||
|
// }
|
||||||
|
// stopEventFromActivatingCardWideActions(ev);
|
||||||
|
// }}
|
||||||
|
// outlined=""
|
||||||
|
// >
|
||||||
|
// <ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
|
||||||
|
// </ha-card>`
|
||||||
|
// : ''}
|
||||||
|
// ${results.map((child, index) =>
|
||||||
|
// html`
|
||||||
|
// ${child.can_expand
|
||||||
|
// ? html`
|
||||||
|
// <ha-card
|
||||||
|
// @click=${(ev) => {
|
||||||
|
// if (this.hass && this.view) {
|
||||||
|
// fetchChildMediaAndDispatchViewChange(
|
||||||
|
// this,
|
||||||
|
// this.hass,
|
||||||
|
// this.view,
|
||||||
|
// child,
|
||||||
|
// {
|
||||||
|
// gallery: {
|
||||||
|
// previous: this.view,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// stopEventFromActivatingCardWideActions(ev);
|
||||||
|
// }}
|
||||||
|
// outlined=""
|
||||||
|
// >
|
||||||
|
// <div>${child.title}</div>
|
||||||
|
// </ha-card>
|
||||||
|
// `
|
||||||
|
// : html`<frigate-card-thumbnail
|
||||||
|
// .view=${this.view}
|
||||||
|
// .target=${this.view?.target ?? null}
|
||||||
|
// .childIndex=${index}
|
||||||
|
// .hass=${this.hass}
|
||||||
|
// .cameraConfig=${child.frigate?.cameraID
|
||||||
|
// ? this.cameras?.get(child.frigate.cameraID)
|
||||||
|
// : undefined}
|
||||||
|
// ?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||||
|
// ?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
||||||
|
// .show_favorite_control}
|
||||||
|
// ?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
|
||||||
|
// .show_timeline_control}
|
||||||
|
// @click=${(ev: Event) => {
|
||||||
|
// if (this.view) {
|
||||||
|
// const targetView = this.view.getViewerViewForGalleryView();
|
||||||
|
// if (targetView) {
|
||||||
|
// this.view
|
||||||
|
// .evolve({
|
||||||
|
// view: targetView,
|
||||||
|
// childIndex: index,
|
||||||
|
// })
|
||||||
|
// .dispatchChangeEvent(this);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// stopEventFromActivatingCardWideActions(ev);
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// </frigate-card-thumbnail>`}
|
||||||
|
// `,
|
||||||
|
// )}
|
||||||
|
// `;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Get styles.
|
||||||
|
// */
|
||||||
|
// static get styles(): CSSResultGroup {
|
||||||
|
// return unsafeCSS(galleryStyle);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'frigate-card-gallery-core': FrigateCardGalleryCore;
|
//'frigate-card-gallery-core': FrigateCardGalleryCore;
|
||||||
'frigate-card-gallery': FrigateCardGallery;
|
'frigate-card-gallery': FrigateCardGallery;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-22
@@ -33,7 +33,6 @@ import {
|
|||||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||||
import { contentsChanged } from '../../utils/basic.js';
|
import { contentsChanged } from '../../utils/basic.js';
|
||||||
import { getCameraIcon, getCameraTitle } from '../../utils/camera.js';
|
import { getCameraIcon, getCameraTitle } from '../../utils/camera.js';
|
||||||
import { getFullDependentBrowseMediaQueryParameters } from '../../utils/ha/browse-media.js';
|
|
||||||
import {
|
import {
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
dispatchMediaUnloadedEvent,
|
dispatchMediaUnloadedEvent,
|
||||||
@@ -52,7 +51,7 @@ import '../surround.js';
|
|||||||
import { EmblaCarouselPlugins } from '../carousel.js';
|
import { EmblaCarouselPlugins } from '../carousel.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||||
import { DataManager } from '../../utils/data-manager.js';
|
import { DataManager } from '../../utils/data/data-manager.js';
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
|
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
|
||||||
import { HassEntity } from 'home-assistant-js-websocket';
|
import { HassEntity } from 'home-assistant-js-websocket';
|
||||||
@@ -212,16 +211,6 @@ export class FrigateCardLive extends LitElement {
|
|||||||
this.conditionState,
|
this.conditionState,
|
||||||
) as LiveConfig;
|
) as LiveConfig;
|
||||||
|
|
||||||
// Does not use getFullDependentBrowseMediaQueryParametersOrDispatchError to
|
|
||||||
// ensure that non-Frigate cameras will work in live view (they will not
|
|
||||||
// have a Frigate camera name).
|
|
||||||
const browseMediaParams = getFullDependentBrowseMediaQueryParameters(
|
|
||||||
this.hass,
|
|
||||||
this.cameras,
|
|
||||||
this.view.camera,
|
|
||||||
config.controls.thumbnails.media,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notes:
|
// Notes:
|
||||||
// - See use of liveConfig and not config below -- the carousel will
|
// - See use of liveConfig and not config below -- the carousel will
|
||||||
// independently override the liveConfig to reflect the camera in the
|
// independently override the liveConfig to reflect the camera in the
|
||||||
@@ -238,10 +227,9 @@ export class FrigateCardLive extends LitElement {
|
|||||||
html`<frigate-card-surround
|
html`<frigate-card-surround
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.fetch=${true}
|
.fetchMedia=${config.controls.thumbnails.media}
|
||||||
.thumbnailConfig=${config.controls.thumbnails}
|
.thumbnailConfig=${config.controls.thumbnails}
|
||||||
.timelineConfig=${config.controls.timeline}
|
.timelineConfig=${config.controls.timeline}
|
||||||
.browseMediaParams=${browseMediaParams ?? undefined}
|
|
||||||
.cameras=${this.cameras}
|
.cameras=${this.cameras}
|
||||||
.dataManager=${this.dataManager}
|
.dataManager=${this.dataManager}
|
||||||
.inBackground=${this._inBackground}
|
.inBackground=${this._inBackground}
|
||||||
@@ -375,16 +363,19 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _getSelectedCameraIndex(): number {
|
||||||
|
if (!this.cameras || !this.view) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Embla options to use.
|
* Get the Embla options to use.
|
||||||
* @returns An EmblaOptionsType object or undefined for no options.
|
* @returns An EmblaOptionsType object or undefined for no options.
|
||||||
*/
|
*/
|
||||||
protected _getOptions(): EmblaOptionsType {
|
protected _getOptions(): EmblaOptionsType {
|
||||||
return {
|
return {
|
||||||
startIndex:
|
|
||||||
this.cameras && this.view
|
|
||||||
? Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera))
|
|
||||||
: 0,
|
|
||||||
draggable: this.liveConfig?.draggable,
|
draggable: this.liveConfig?.draggable,
|
||||||
loop: true,
|
loop: true,
|
||||||
};
|
};
|
||||||
@@ -483,10 +474,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
this.view
|
this.view
|
||||||
.evolve({
|
.evolve({
|
||||||
camera: Array.from(this.cameras.keys())[selectedCameraIndex],
|
camera: Array.from(this.cameras.keys())[selectedCameraIndex],
|
||||||
|
// Reset the query and query results.
|
||||||
// Reset the target.
|
query: null,
|
||||||
target: null,
|
queryResults: null,
|
||||||
childIndex: null,
|
|
||||||
})
|
})
|
||||||
// Don't yet fetch thumbnails (they will be fetched when the carousel
|
// Don't yet fetch thumbnails (they will be fetched when the carousel
|
||||||
// settles).
|
// settles).
|
||||||
@@ -624,6 +614,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
) as EmblaCarouselPlugins}
|
) as EmblaCarouselPlugins}
|
||||||
.label="${title ? `${localize('common.live')}: ${title}` : ''}"
|
.label="${title ? `${localize('common.live')}: ${title}` : ''}"
|
||||||
.titlePopupConfig=${config.controls.title}
|
.titlePopupConfig=${config.controls.title}
|
||||||
|
.selected=${this._getSelectedCameraIndex()}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||||
@frigate-card:carousel:settle=${() => {
|
@frigate-card:carousel:settle=${() => {
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export class FrigateCardMediaCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public carouselPlugins?: EmblaCarouselPlugins;
|
public carouselPlugins?: EmblaCarouselPlugins;
|
||||||
|
|
||||||
|
@property({ attribute: false, type: Number })
|
||||||
|
public selected = 0;
|
||||||
|
|
||||||
@property({ attribute: true })
|
@property({ attribute: true })
|
||||||
public transitionEffect?: TransitionEffect;
|
public transitionEffect?: TransitionEffect;
|
||||||
|
|
||||||
@@ -418,6 +421,7 @@ export class FrigateCardMediaCarousel extends LitElement {
|
|||||||
|
|
||||||
return html` <frigate-card-carousel
|
return html` <frigate-card-carousel
|
||||||
${ref(this._refCarousel)}
|
${ref(this._refCarousel)}
|
||||||
|
.selected=${this.selected ?? 0}
|
||||||
.carouselOptions=${this.carouselOptions}
|
.carouselOptions=${this.carouselOptions}
|
||||||
.carouselPlugins=${this.carouselPlugins}
|
.carouselPlugins=${this.carouselPlugins}
|
||||||
transitionEffect=${ifDefined(this.transitionEffect)}
|
transitionEffect=${ifDefined(this.transitionEffect)}
|
||||||
|
|||||||
@@ -177,9 +177,11 @@ export function dispatchErrorMessageEvent(
|
|||||||
*/
|
*/
|
||||||
export function dispatchFrigateCardErrorEvent(
|
export function dispatchFrigateCardErrorEvent(
|
||||||
element: EventTarget,
|
element: EventTarget,
|
||||||
error: FrigateCardError,
|
error: FrigateCardError | Error,
|
||||||
): void {
|
): void {
|
||||||
dispatchErrorMessageEvent(element, error.message, { context: error.context });
|
dispatchErrorMessageEvent(element, error.message, {
|
||||||
|
...(error instanceof FrigateCardError && { context: error.context }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
+28
-44
@@ -7,29 +7,20 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import surroundStyle from '../scss/surround.scss';
|
import surroundStyle from '../scss/surround.scss';
|
||||||
import {
|
import {
|
||||||
BrowseMediaQueryParameters,
|
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
|
ClipsOrSnapshotsOrAll,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
FrigateCardError,
|
|
||||||
MiniTimelineControlConfig,
|
MiniTimelineControlConfig,
|
||||||
ThumbnailsControlConfig,
|
ThumbnailsControlConfig,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
import {
|
import { DataManager } from '../utils/data/data-manager.js';
|
||||||
getFirstTrueMediaChildIndex,
|
|
||||||
multipleBrowseMediaQueryMerged,
|
|
||||||
} from '../utils/ha/browse-media';
|
|
||||||
import { DataManager } from '../utils/data-manager';
|
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
|
||||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||||
|
|
||||||
import './surround-basic.js';
|
import './surround-basic.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
|
||||||
|
|
||||||
interface ThumbnailViewContext {
|
interface ThumbnailViewContext {
|
||||||
// Whether or not to fetch thumbnails.
|
// Whether or not to fetch thumbnails.
|
||||||
@@ -59,11 +50,9 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public inBackground?: boolean;
|
public inBackground?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false })
|
// If fetchMedia is not specified, no fetching is done.
|
||||||
public fetch = false;
|
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
|
public fetchMedia?: ClipsOrSnapshotsOrAll;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameras?: Map<string, CameraConfig>;
|
public cameras?: Map<string, CameraConfig>;
|
||||||
@@ -79,32 +68,29 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected async _fetchMedia(): Promise<void> {
|
protected async _fetchMedia(): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!this.fetch ||
|
!this.cameras ||
|
||||||
|
!this.dataManager ||
|
||||||
|
!this.fetchMedia ||
|
||||||
this.inBackground ||
|
this.inBackground ||
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.view ||
|
!this.view ||
|
||||||
this.view.target ||
|
this.view.query ||
|
||||||
!this.thumbnailConfig ||
|
!this.thumbnailConfig ||
|
||||||
this.thumbnailConfig.mode === 'none' ||
|
this.thumbnailConfig.mode === 'none' ||
|
||||||
!this.browseMediaParams ||
|
|
||||||
!(this.view.context?.thumbnails?.fetch ?? true)
|
!(this.view.context?.thumbnails?.fetch ?? true)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let parent: FrigateBrowseMediaSource | null;
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
try {
|
this,
|
||||||
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
|
this.hass,
|
||||||
} catch (e) {
|
this.dataManager,
|
||||||
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
this.cameras,
|
||||||
}
|
this.view,
|
||||||
if (getFirstTrueMediaChildIndex(parent) !== null) {
|
{
|
||||||
this.view
|
mediaType: this.fetchMedia,
|
||||||
?.evolve({
|
},
|
||||||
target: parent,
|
);
|
||||||
childIndex: null,
|
|
||||||
})
|
|
||||||
.dispatchChangeEvent(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,25 +156,21 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
slot=${this.thumbnailConfig.mode}
|
slot=${this.thumbnailConfig.mode}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.config=${this.thumbnailConfig}
|
.config=${this.thumbnailConfig}
|
||||||
|
.dataManager=${this.dataManager}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.target=${this.view.target}
|
|
||||||
.cameras=${this.cameras}
|
.cameras=${this.cameras}
|
||||||
selected=${ifDefined(this.view.childIndex ?? undefined)}
|
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
|
||||||
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||||
@frigate-card:thumbnail-carousel:tap=${(
|
@frigate-card:thumbnail-carousel:tap=${(
|
||||||
ev: CustomEvent<ThumbnailCarouselTap>,
|
ev: CustomEvent<ThumbnailCarouselTap>,
|
||||||
) => {
|
) => {
|
||||||
const child: FrigateBrowseMediaSource | null =
|
const media = ev.detail.queryResults.getSelectedResult();
|
||||||
ev.detail.target?.children?.[ev.detail.childIndex] ?? null;
|
if (media) {
|
||||||
if (child) {
|
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
view: this.view.is('recording') ? 'recording' : 'media',
|
view: this.view.is('recording') ? 'recording' : 'media',
|
||||||
target: ev.detail.target,
|
queryResults: ev.detail.queryResults,
|
||||||
childIndex: ev.detail.childIndex,
|
...(media.getCameraID() && { camera: media.getCameraID() }),
|
||||||
...(child.frigate?.cameraID && {
|
|
||||||
camera: child.frigate?.cameraID,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.removeContext('timeline')
|
.removeContext('timeline')
|
||||||
// Send the view change from the source of the tap event, so
|
// Send the view change from the source of the tap event, so
|
||||||
@@ -200,7 +182,9 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
>
|
>
|
||||||
</frigate-card-thumbnail-carousel>`
|
</frigate-card-thumbnail-carousel>`
|
||||||
: ''}
|
: ''}
|
||||||
${this.timelineConfig?.mode && this.timelineConfig.mode !== 'none' && !this.inBackground
|
${this.timelineConfig?.mode &&
|
||||||
|
this.timelineConfig.mode !== 'none' &&
|
||||||
|
!this.inBackground
|
||||||
? html` <frigate-card-timeline-core
|
? html` <frigate-card-timeline-core
|
||||||
slot=${this.timelineConfig.mode}
|
slot=${this.timelineConfig.mode}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
|||||||
@@ -15,22 +15,19 @@ import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
|||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
ThumbnailsControlConfig,
|
ThumbnailsControlConfig,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
import { isTrueMedia } from '../utils/ha/browse-media';
|
import { MediaQueriesResults, View } from '../view.js';
|
||||||
import { View } from '../view.js';
|
|
||||||
import { FrigateCardCarousel } from './carousel.js';
|
import { FrigateCardCarousel } from './carousel.js';
|
||||||
import './thumbnail.js';
|
import './thumbnail.js';
|
||||||
import './carousel.js';
|
import './carousel.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
import { DataManager } from '../utils/data/data-manager.js';
|
||||||
|
|
||||||
export interface ThumbnailCarouselTap {
|
export interface ThumbnailCarouselTap {
|
||||||
slideIndex: number;
|
queryResults: MediaQueriesResults;
|
||||||
target: FrigateBrowseMediaSource;
|
|
||||||
childIndex: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-thumbnail-carousel')
|
@customElement('frigate-card-thumbnail-carousel')
|
||||||
@@ -41,14 +38,12 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
// Use contentsChanged here to avoid the carousel rebuilding and resetting in
|
|
||||||
// front of the user, unless the contents have actually changed.
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
|
||||||
public target?: FrigateBrowseMediaSource | null;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameras?: Map<string, CameraConfig>;
|
public cameras?: Map<string, CameraConfig>;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public dataManager?: DataManager;
|
||||||
|
|
||||||
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
|
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
|
||||||
|
|
||||||
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
|
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
|
||||||
@@ -59,10 +54,14 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public config?: ThumbnailsControlConfig;
|
public config?: ThumbnailsControlConfig;
|
||||||
|
|
||||||
@property({ attribute: true, type: Number, reflect: true })
|
@property({ attribute: false })
|
||||||
public selected?: number;
|
public selected? = 0;
|
||||||
|
|
||||||
|
protected _carouselOptions?: EmblaOptionsType = {
|
||||||
|
containScroll: 'keepSnaps',
|
||||||
|
dragFree: true,
|
||||||
|
};
|
||||||
|
|
||||||
protected _carouselOptions?: EmblaOptionsType;
|
|
||||||
protected _carouselPlugins: EmblaPluginType[] = [
|
protected _carouselPlugins: EmblaPluginType[] = [
|
||||||
WheelGesturesPlugin({
|
WheelGesturesPlugin({
|
||||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||||
@@ -99,31 +98,20 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Embla options to use.
|
|
||||||
* @returns An EmblaOptionsType object or undefined for no options.
|
|
||||||
*/
|
|
||||||
protected _getOptions(): EmblaOptionsType {
|
|
||||||
return {
|
|
||||||
containScroll: 'keepSnaps',
|
|
||||||
dragFree: true,
|
|
||||||
startIndex: this.selected ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Get slides to include in the render.
|
* Get slides to include in the render.
|
||||||
* @returns The slides to include in the render.
|
* @returns The slides to include in the render.
|
||||||
*/
|
*/
|
||||||
protected _getSlides(): TemplateResult[] {
|
protected _getSlides(): TemplateResult[] {
|
||||||
if (!this.target || !this.target.children || !this.target.children.length) {
|
if (!this.view?.query || !this.view.queryResults?.hasResults()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const slides: TemplateResult[] = [];
|
const slides: TemplateResult[] = [];
|
||||||
for (let i = 0; i < this.target.children.length; ++i) {
|
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
||||||
const thumbnail = this._renderThumbnail(this.target, i, slides.length);
|
const thumbnail = this._renderThumbnail(i);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
slides.push(thumbnail);
|
slides[i] = thumbnail;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slides;
|
return slides;
|
||||||
@@ -152,30 +140,6 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
this.selected === undefined ? '1.0' : '0.4',
|
this.selected === undefined ? '1.0' : '0.4',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._carouselOptions) {
|
|
||||||
// Want to set the initial carousel options just before the first render
|
|
||||||
// in order to get the startIndex correct in the options. It is not safe
|
|
||||||
// to rely on carouselScrollTo() post update, since the nested carousel
|
|
||||||
// may not yet be actual rendered/created.
|
|
||||||
this._carouselOptions = this._getOptions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The updated lifecycle callback for this element.
|
|
||||||
* @param changedProperties The properties that were changed in this render.
|
|
||||||
*/
|
|
||||||
updated(changedProperties: PropertyValues): void {
|
|
||||||
super.updated(changedProperties);
|
|
||||||
|
|
||||||
if (changedProperties.has('selected')) {
|
|
||||||
this.updateComplete.then(() => {
|
|
||||||
if (this.selected !== undefined) {
|
|
||||||
this._refCarousel.value?.carouselScrollTo(this.selected);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,45 +147,40 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
* @param mediaToRender The media item to render.
|
* @param mediaToRender The media item to render.
|
||||||
* @returns A template or void if the item could not be rendered.
|
* @returns A template or void if the item could not be rendered.
|
||||||
*/
|
*/
|
||||||
protected _renderThumbnail(
|
protected _renderThumbnail(index: number): TemplateResult | void {
|
||||||
parent: FrigateBrowseMediaSource,
|
const media = this.view?.queryResults?.getResult(index) ?? null;
|
||||||
childIndex: number,
|
const cameraConfig = media ? this.cameras?.get(media.getCameraID()) : null;
|
||||||
slideIndex: number,
|
if (!media || !cameraConfig || !this.view) {
|
||||||
): TemplateResult | void {
|
|
||||||
if (
|
|
||||||
!parent.children ||
|
|
||||||
!parent.children.length ||
|
|
||||||
!isTrueMedia(parent.children[childIndex])
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
embla__slide: true,
|
embla__slide: true,
|
||||||
'slide-selected': this.selected === childIndex,
|
'slide-selected': this.selected === index,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null;
|
|
||||||
return html` <frigate-card-thumbnail
|
return html` <frigate-card-thumbnail
|
||||||
|
class="${classMap(classes)}"
|
||||||
|
.dataManager=${this.dataManager}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.media=${media}
|
||||||
|
.cameraConfig=${cameraConfig}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.target=${parent}
|
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(index)}
|
||||||
.childIndex=${childIndex}
|
?details=${!!this.config?.show_details}
|
||||||
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)}
|
|
||||||
.cameraConfig=${cameraConfig ?? undefined}
|
|
||||||
?details=${this.config?.show_details}
|
|
||||||
?show_favorite_control=${this.config?.show_favorite_control}
|
?show_favorite_control=${this.config?.show_favorite_control}
|
||||||
?show_timeline_control=${this.config?.show_timeline_control}
|
?show_timeline_control=${this.config?.show_timeline_control}
|
||||||
class="${classMap(classes)}"
|
@click=${(ev: Event) => {
|
||||||
@click=${(ev) => {
|
if (
|
||||||
if (this._refCarousel.value?.carouselClickAllowed()) {
|
this.view &&
|
||||||
|
this.view.queryResults &&
|
||||||
|
this._refCarousel.value?.carouselClickAllowed()
|
||||||
|
) {
|
||||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||||
this,
|
this,
|
||||||
'thumbnail-carousel:tap',
|
'thumbnail-carousel:tap',
|
||||||
{
|
{
|
||||||
slideIndex: slideIndex,
|
queryResults: this.view.queryResults.clone().selectResult(index),
|
||||||
target: parent,
|
|
||||||
childIndex: childIndex,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,6 +216,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
return html`<frigate-card-carousel
|
return html`<frigate-card-carousel
|
||||||
${ref(this._refCarousel)}
|
${ref(this._refCarousel)}
|
||||||
direction=${ifDefined(this._getDirection())}
|
direction=${ifDefined(this._getDirection())}
|
||||||
|
.selected=${this.selected ?? 0}
|
||||||
.carouselOptions=${this._carouselOptions}
|
.carouselOptions=${this._carouselOptions}
|
||||||
.carouselPlugins=${this._carouselPlugins}
|
.carouselPlugins=${this._carouselPlugins}
|
||||||
>
|
>
|
||||||
|
|||||||
+111
-121
@@ -3,30 +3,24 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
|||||||
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
||||||
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
||||||
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
||||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import { errorToConsole, prettifyTitle } from '../utils/basic.js';
|
import { getDurationString, prettifyTitle } from '../utils/basic.js';
|
||||||
import { getCameraTitle } from '../utils/camera.js';
|
import { getCameraTitle } from '../utils/camera.js';
|
||||||
import { retainEvent } from '../utils/frigate.js';
|
|
||||||
import { getEventDurationString } from '../utils/frigate.js';
|
|
||||||
import { renderTask } from '../utils/task.js';
|
import { renderTask } from '../utils/task.js';
|
||||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
import type { MediaSeek } from './viewer.js';
|
import type { MediaSeek } from './viewer.js';
|
||||||
import { TaskStatus } from '@lit-labs/task';
|
import { TaskStatus } from '@lit-labs/task';
|
||||||
|
|
||||||
import type {
|
import type { CameraConfig, ExtendedHomeAssistant } from '../types.js';
|
||||||
CameraConfig,
|
import { ViewMedia } from '../view-media.js';
|
||||||
ExtendedHomeAssistant,
|
import { DataManager } from '../utils/data/data-manager.js';
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
FrigateEvent,
|
|
||||||
FrigateRecording,
|
|
||||||
} from '../types.js';
|
|
||||||
// The minimum width of a thumbnail with details enabled.
|
// The minimum width of a thumbnail with details enabled.
|
||||||
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||||
|
|
||||||
@@ -133,26 +127,36 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
|
|||||||
@customElement('frigate-card-thumbnail-details-event')
|
@customElement('frigate-card-thumbnail-details-event')
|
||||||
export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public event?: FrigateEvent;
|
public media?: ViewMedia;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public mediaSeek?: MediaSeek;
|
public mediaSeek?: MediaSeek;
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.event) {
|
if (!this.media || !this.media.isEvent()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const score = (this.event.top_score * 100).toFixed(2) + '%';
|
const score = this.media.getScore();
|
||||||
return html`<div class="left">
|
const startTime = this.media.getStartTime();
|
||||||
<div class="larger">${prettifyTitle(this.event.label)}</div>
|
const endTime = this.media.getEndTime();
|
||||||
<div>
|
const what = this.media.getWhat();
|
||||||
<span class="heading">${localize('event.start')}:</span>
|
|
||||||
<span>${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')}</span>
|
return html` <div class="left">
|
||||||
</div>
|
${what ? html`<div class="larger">${prettifyTitle(what.join(', '))}</div>` : ``}
|
||||||
<div>
|
${startTime
|
||||||
<span class="heading">${localize('event.duration')}:</span>
|
? html` <div>
|
||||||
<span>${getEventDurationString(this.event)}</span>
|
<span class="heading">${localize('event.start')}:</span>
|
||||||
</div>
|
<span>${format(startTime, 'HH:mm:ss')}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="heading">${localize('event.duration')}:</span>
|
||||||
|
<span
|
||||||
|
>${endTime
|
||||||
|
? getDurationString(startTime, endTime)
|
||||||
|
: localize('event.in_progress')}</span
|
||||||
|
>
|
||||||
|
</div>`
|
||||||
|
: ``}
|
||||||
${this.mediaSeek
|
${this.mediaSeek
|
||||||
? html` <div>
|
? html` <div>
|
||||||
<span class="heading">${localize('event.seek')}</span>
|
<span class="heading">${localize('event.seek')}</span>
|
||||||
@@ -160,9 +164,11 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
|||||||
</div>`
|
</div>`
|
||||||
: html``}
|
: html``}
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
${score
|
||||||
<span class="larger">${score}</span>
|
? html`<div class="right">
|
||||||
</div>`;
|
<span class="larger">${(score * 100).toFixed(2) + '%'}</span>
|
||||||
|
</div>`
|
||||||
|
: ``}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
static get styles(): CSSResult {
|
||||||
@@ -173,17 +179,21 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
|||||||
@customElement('frigate-card-thumbnail-details-recording')
|
@customElement('frigate-card-thumbnail-details-recording')
|
||||||
export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public recording?: FrigateRecording;
|
public media?: ViewMedia;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public mediaSeek?: MediaSeek;
|
public mediaSeek?: MediaSeek;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameraTitle?: string;
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.recording) {
|
if (!this.media) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const eventCount = this.media.getEventCount();
|
||||||
return html`<div class="left">
|
return html`<div class="left">
|
||||||
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
|
<div class="larger">${this.cameraTitle ?? ''}</div>
|
||||||
${this.mediaSeek
|
${this.mediaSeek
|
||||||
? html` <div>
|
? html` <div>
|
||||||
<span class="heading">${localize('recording.seek')}</span>
|
<span class="heading">${localize('recording.seek')}</span>
|
||||||
@@ -191,10 +201,12 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
|||||||
</div>`
|
</div>`
|
||||||
: html``}
|
: html``}
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
${eventCount !== null
|
||||||
<span class="larger">${this.recording.events}</span>
|
? html`<div class="right">
|
||||||
<span>${localize('recording.events')}</span>
|
<span class="larger">${eventCount}</span>
|
||||||
</div>`;
|
<span>${localize('recording.events')}</span>
|
||||||
|
</div>`
|
||||||
|
: ``}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
static get styles(): CSSResult {
|
||||||
@@ -204,6 +216,21 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
|||||||
|
|
||||||
@customElement('frigate-card-thumbnail')
|
@customElement('frigate-card-thumbnail')
|
||||||
export class FrigateCardThumbnail extends LitElement {
|
export class FrigateCardThumbnail extends LitElement {
|
||||||
|
// HomeAssistant object may be required for thumbnail signing (for Frigate
|
||||||
|
// events).
|
||||||
|
@property({ attribute: false })
|
||||||
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
// DataManager used for marking media as favorite.
|
||||||
|
@property({ attribute: false })
|
||||||
|
public dataManager?: DataManager;
|
||||||
|
|
||||||
|
@property({ attribute: true })
|
||||||
|
public media?: ViewMedia;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: true, type: Boolean })
|
||||||
public details = false;
|
public details = false;
|
||||||
|
|
||||||
@@ -213,160 +240,123 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: true, type: Boolean })
|
||||||
public show_timeline_control = false;
|
public show_timeline_control = false;
|
||||||
|
|
||||||
// ======================
|
|
||||||
// Target-based interface
|
|
||||||
// ======================
|
|
||||||
@property({ attribute: false })
|
|
||||||
public target?: FrigateBrowseMediaSource | null;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public childIndex?: number;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public mediaSeek?: MediaSeek;
|
public mediaSeek?: MediaSeek;
|
||||||
|
|
||||||
// ===================================================
|
|
||||||
// Raw interface (can override target-based interface)
|
|
||||||
// ===================================================
|
|
||||||
@property({ attribute: true })
|
|
||||||
public thumbnail?: string;
|
|
||||||
|
|
||||||
@property({ attribute: true })
|
|
||||||
public label?: string;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public event?: FrigateEvent;
|
|
||||||
|
|
||||||
// ================================
|
|
||||||
// Optional parameters for controls
|
|
||||||
// ================================
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public hass?: ExtendedHomeAssistant;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cameraConfig?: CameraConfig;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the element.
|
* Render the element.
|
||||||
* @returns A template to display to the user.
|
* @returns A template to display to the user.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
let event: FrigateEvent | null = null;
|
if (!this.media || !this.cameraConfig) {
|
||||||
let recording: FrigateRecording | null = null;
|
|
||||||
let thumbnail: string | null = null;
|
|
||||||
let label: string | null = null;
|
|
||||||
|
|
||||||
// Take the event / thumbnail / label from the data-bound media (if specified).
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always give the overrides preference (if specified).
|
|
||||||
if (this.event) {
|
|
||||||
event = this.event;
|
|
||||||
}
|
|
||||||
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
|
|
||||||
label = this.label ? this.label : label;
|
|
||||||
|
|
||||||
if (!event && !recording) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thumbnail = this.media.getThumbnail(this.cameraConfig);
|
||||||
|
const title = this.media.getTitle(this.cameraConfig) ?? '';
|
||||||
|
|
||||||
const starClasses = {
|
const starClasses = {
|
||||||
star: true,
|
star: true,
|
||||||
starred: !!event?.retain_indefinitely,
|
starred: !!this.media?.isFavorite(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldShowTimelineControl =
|
||||||
|
this.show_timeline_control &&
|
||||||
|
this.view &&
|
||||||
|
(!this.media.isRecording() ||
|
||||||
|
// Only show timeline control if the recording has a start & end time.
|
||||||
|
(this.media.getStartTime() && this.media.getEndTime()));
|
||||||
|
|
||||||
const clientID = this.cameraConfig?.frigate.client_id;
|
const clientID = this.cameraConfig?.frigate.client_id;
|
||||||
return html` ${event
|
return html` ${this.media.isEvent()
|
||||||
? html`<frigate-card-thumbnail-feature-event
|
? html`<frigate-card-thumbnail-feature-event
|
||||||
aria-label="${label ?? ''}"
|
aria-label="${title ?? ''}"
|
||||||
title="${label ?? ''}"
|
title=${title}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.thumbnail=${thumbnail ?? undefined}
|
.thumbnail=${thumbnail ?? undefined}
|
||||||
.label=${label ?? undefined}
|
|
||||||
></frigate-card-thumbnail-feature-event>`
|
></frigate-card-thumbnail-feature-event>`
|
||||||
: recording
|
: this.media.isRecording()
|
||||||
? html`<frigate-card-thumbnail-feature-recording
|
? html`<frigate-card-thumbnail-feature-recording
|
||||||
aria-label="${label ?? ''}"
|
aria-label="${title ?? ''}"
|
||||||
title="${label ?? ''}"
|
title="${title ?? ''}"
|
||||||
.cameraTitle=${this.details || !this.cameraConfig || !this.hass
|
.cameraTitle=${this.details || !this.cameraConfig || !this.hass
|
||||||
? undefined
|
? undefined
|
||||||
: getCameraTitle(this.hass, this.cameraConfig)}
|
: getCameraTitle(this.hass, this.cameraConfig)}
|
||||||
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
|
.date=${this.media.getStartTime() ?? undefined}
|
||||||
></frigate-card-thumbnail-feature-recording>`
|
></frigate-card-thumbnail-feature-recording>`
|
||||||
: html``}
|
: html``}
|
||||||
${this.show_favorite_control && event && this.hass && clientID
|
${this.show_favorite_control && event && this.hass && clientID
|
||||||
? html` <ha-icon
|
? html` <ha-icon
|
||||||
class="${classMap(starClasses)}"
|
class="${classMap(starClasses)}"
|
||||||
icon=${event?.retain_indefinitely ? 'mdi:star' : 'mdi:star-outline'}
|
icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'}
|
||||||
title=${localize('thumbnail.retain_indefinitely')}
|
title=${localize('thumbnail.retain_indefinitely')}
|
||||||
@click=${(ev: Event) => {
|
@click=${(ev: Event) => {
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
if (event && this.hass && clientID) {
|
if (this.hass && this.cameraConfig && this.media) {
|
||||||
retainEvent(this.hass, clientID, event.id, !event.retain_indefinitely)
|
this.dataManager?.favoriteMedia(
|
||||||
.then(() => {
|
this.hass,
|
||||||
if (event) {
|
this.cameraConfig,
|
||||||
event.retain_indefinitely = !event.retain_indefinitely;
|
this.media,
|
||||||
this.requestUpdate();
|
!this.media?.isFavorite(),
|
||||||
}
|
);
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
errorToConsole(e);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/></ha-icon>`
|
/></ha-icon>`
|
||||||
: ``}
|
: ``}
|
||||||
${this.details && event
|
${this.details && this.media.isEvent()
|
||||||
? html`<frigate-card-thumbnail-details-event
|
? html`<frigate-card-thumbnail-details-event
|
||||||
.event=${event ?? undefined}
|
.media=${this.media ?? undefined}
|
||||||
.mediaSeek=${this.mediaSeek}
|
.mediaSeek=${this.mediaSeek}
|
||||||
></frigate-card-thumbnail-details-event>`
|
></frigate-card-thumbnail-details-event>`
|
||||||
: this.details && recording
|
: this.details && this.media.isRecording()
|
||||||
? html`<frigate-card-thumbnail-details-recording
|
? html`<frigate-card-thumbnail-details-recording
|
||||||
.recording=${recording ?? undefined}
|
.media=${this.media ?? undefined}
|
||||||
|
.cameraTitle=${getCameraTitle(this.hass, this.cameraConfig)}
|
||||||
.mediaSeek=${this.mediaSeek}
|
.mediaSeek=${this.mediaSeek}
|
||||||
></frigate-card-thumbnail-details-recording>`
|
></frigate-card-thumbnail-details-recording>`
|
||||||
: html``}
|
: html``}
|
||||||
${this.show_timeline_control
|
${shouldShowTimelineControl
|
||||||
? html`<ha-icon
|
? html`<ha-icon
|
||||||
class="timeline"
|
class="timeline"
|
||||||
icon="mdi:target"
|
icon="mdi:target"
|
||||||
title=${localize('thumbnail.timeline')}
|
title=${localize('thumbnail.timeline')}
|
||||||
@click=${(ev: Event) => {
|
@click=${(ev: Event) => {
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
if (event) {
|
if (!this.view || !this.media) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.media.isEvent()) {
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
.evolve({
|
||||||
view: 'timeline',
|
view: 'timeline',
|
||||||
target: this.target,
|
queryResults: this.view.queryResults
|
||||||
childIndex: this.childIndex ?? null,
|
?.clone()
|
||||||
|
.selectResultIfFound((media) => media === this.media),
|
||||||
})
|
})
|
||||||
.removeContext('timeline')
|
.removeContext('timeline')
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
} else if (recording) {
|
} else if (this.media.isRecording()) {
|
||||||
|
const startTime = this.media.getStartTime();
|
||||||
|
const endTime = this.media.getStartTime();
|
||||||
|
if (!startTime || !endTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Specifically reset the media target/childIndex, as we cannot
|
// Specifically reset the media target/childIndex, as we cannot
|
||||||
// 'select' an hour in the timeline rather we set the window to
|
// 'select' an hour in the timeline rather we set the window to
|
||||||
// matching values.
|
// matching values.
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
view: 'timeline',
|
view: 'timeline',
|
||||||
target: null,
|
query: null,
|
||||||
childIndex: null,
|
|
||||||
})
|
})
|
||||||
.mergeInContext({
|
.mergeInContext({
|
||||||
timeline: {
|
timeline: {
|
||||||
window: {
|
window: {
|
||||||
start: fromUnixTime(recording.start_time),
|
start: startTime,
|
||||||
end: fromUnixTime(recording.end_time),
|
end: endTime,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+480
-441
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import timelineStyle from '../scss/timeline.scss';
|
import timelineStyle from '../scss/timeline.scss';
|
||||||
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||||
import { DataManager } from '../utils/data-manager';
|
import { DataManager } from '../utils/data/data-manager';
|
||||||
import { View } from '../view';
|
import { View } from '../view';
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import './timeline-core.js';
|
import './timeline-core.js';
|
||||||
@@ -43,7 +43,6 @@ export class FrigateCardTimeline extends LitElement {
|
|||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
|
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
|
||||||
.cameras=${this.cameras}
|
.cameras=${this.cameras}
|
||||||
.fetch=${false}
|
|
||||||
>
|
>
|
||||||
<frigate-card-timeline-core
|
<frigate-card-timeline-core
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
|||||||
+187
-304
@@ -16,30 +16,21 @@ import { renderProgressIndicator } from '../components/message.js';
|
|||||||
import viewerStyle from '../scss/viewer.scss';
|
import viewerStyle from '../scss/viewer.scss';
|
||||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||||
import {
|
import {
|
||||||
BrowseMediaNeighbors,
|
|
||||||
BrowseMediaQueryParameters,
|
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
frigateCardConfigDefaults,
|
frigateCardConfigDefaults,
|
||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
|
ResolvedMedia,
|
||||||
TransitionEffect,
|
TransitionEffect,
|
||||||
ViewerConfig,
|
ViewerConfig,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import { contentsChanged } from '../utils/basic.js';
|
import { contentsChanged } from '../utils/basic.js';
|
||||||
import {
|
import { getFullDependentBrowseMediaQueryParametersOrDispatchError } from '../utils/ha/browse-media.js';
|
||||||
fetchLatestMediaAndDispatchViewChange,
|
|
||||||
getEventStartTime,
|
|
||||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
|
||||||
isTrueMedia,
|
|
||||||
multipleBrowseMediaQueryMerged,
|
|
||||||
overrideMultiBrowseMediaQueryParameters,
|
|
||||||
} from '../utils/ha/browse-media.js';
|
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||||
import { View } from '../view.js';
|
import { MediaQueriesResults, View } from '../view.js';
|
||||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||||
import {
|
import {
|
||||||
@@ -55,8 +46,13 @@ import '../patches/ha-hls-player';
|
|||||||
import './surround.js';
|
import './surround.js';
|
||||||
import { renderTask } from '../utils/task.js';
|
import { renderTask } from '../utils/task.js';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||||
import { DataManager } from '../utils/data-manager.js';
|
import { DataManager } from '../utils/data/data-manager.js';
|
||||||
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
|
import {
|
||||||
|
changeViewToRecentEventsForCameraAndDependents,
|
||||||
|
changeViewToRecentRecordingForCameraAndDependents,
|
||||||
|
} from '../utils/media-to-view.js';
|
||||||
|
import { ViewMedia, ViewMediaClassifier } from '../view-media.js';
|
||||||
|
import { guard } from 'lit/directives/guard.js';
|
||||||
|
|
||||||
export interface MediaSeek {
|
export interface MediaSeek {
|
||||||
// Specifies the point at which this recording should be played, the
|
// Specifies the point at which this recording should be played, the
|
||||||
@@ -123,10 +119,10 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
this.view.camera,
|
this.view.camera,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!this.view.target) {
|
if (!this.view.queryResults?.hasResults()) {
|
||||||
// If the target is not specified, the view must tell us which mediaType
|
// If the query is not specified, the view must tell us which mediaType to
|
||||||
// to search for. When the target *is* specified, the view is not required
|
// search for. When the query *is* specified, the view is not required to
|
||||||
// to indicate the media type (e.g. the mixed 'events' view from the
|
// indicate the media type (e.g. the mixed 'media' view from the
|
||||||
// timeline).
|
// timeline).
|
||||||
const mediaType = this.view.getMediaType();
|
const mediaType = this.view.getMediaType();
|
||||||
if (!browseMediaQueryParameters || !mediaType) {
|
if (!browseMediaQueryParameters || !mediaType) {
|
||||||
@@ -145,13 +141,15 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
fetchLatestMediaAndDispatchViewChange(
|
changeViewToRecentEventsForCameraAndDependents(
|
||||||
this,
|
this,
|
||||||
this.hass,
|
this.hass,
|
||||||
|
this.dataManager,
|
||||||
|
this.cameras,
|
||||||
this.view,
|
this.view,
|
||||||
overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
|
{
|
||||||
mediaType: mediaType,
|
targetView: mediaType === 'clips' ? 'clip' : 'snapshot',
|
||||||
}),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||||
@@ -160,7 +158,6 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
return html` <frigate-card-surround
|
return html` <frigate-card-surround
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.fetch=${false}
|
|
||||||
.thumbnailConfig=${this.viewerConfig.controls.thumbnails}
|
.thumbnailConfig=${this.viewerConfig.controls.thumbnails}
|
||||||
.timelineConfig=${this.viewerConfig.controls.timeline}
|
.timelineConfig=${this.viewerConfig.controls.timeline}
|
||||||
.dataManager=${this.dataManager}
|
.dataManager=${this.dataManager}
|
||||||
@@ -169,8 +166,8 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
<frigate-card-viewer-carousel
|
<frigate-card-viewer-carousel
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
|
.cameras=${this.cameras}
|
||||||
.viewerConfig=${this.viewerConfig}
|
.viewerConfig=${this.viewerConfig}
|
||||||
.browseMediaQueryParameters=${browseMediaQueryParameters}
|
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
>
|
>
|
||||||
@@ -204,46 +201,52 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public viewerConfig?: ViewerConfig;
|
public viewerConfig?: ViewerConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public resolvedMediaCache?: ResolvedMediaCache;
|
public resolvedMediaCache?: ResolvedMediaCache;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
@property({ attribute: false })
|
||||||
|
public cameras?: Map<string, CameraConfig>;
|
||||||
|
|
||||||
// Mapping of slide # to FrigateBrowseMediaSource child #.
|
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||||
// (Folders are not media items that can be rendered).
|
|
||||||
protected _slideToChild: Record<number, number> = {};
|
|
||||||
|
|
||||||
protected _carouselOptions?: EmblaOptionsType;
|
protected _carouselOptions?: EmblaOptionsType;
|
||||||
protected _carouselPlugins?: EmblaPluginType[];
|
protected _carouselPlugins?: EmblaPluginType[];
|
||||||
|
|
||||||
// A task to resolve target media if lazy loading is disabled.
|
// A task to resolve target media if lazy loading is disabled.
|
||||||
protected _mediaResolutionTask = new Task<
|
protected _mediaResolutionTask = new Task<
|
||||||
[FrigateBrowseMediaSource | null | undefined],
|
[ViewerConfig | undefined, Map<string, CameraConfig> | undefined, View | undefined],
|
||||||
void
|
void
|
||||||
>(
|
>(
|
||||||
this,
|
this,
|
||||||
async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise<void> => {
|
async ([viewerConfig, cameras, view]: [
|
||||||
for (
|
ViewerConfig | undefined,
|
||||||
let i = 0;
|
Map<string, CameraConfig> | undefined,
|
||||||
!this.viewerConfig?.lazy_load &&
|
View | undefined,
|
||||||
this.hass &&
|
]): Promise<void> => {
|
||||||
target &&
|
if (
|
||||||
target.children &&
|
!this.hass ||
|
||||||
i < (target.children || []).length;
|
!viewerConfig?.lazy_load ||
|
||||||
++i
|
!cameras ||
|
||||||
|
!view ||
|
||||||
|
!view.queryResults?.hasResults()
|
||||||
) {
|
) {
|
||||||
if (isTrueMedia(target.children[i])) {
|
return;
|
||||||
await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const promises: Promise<ResolvedMedia | null>[] = [];
|
||||||
|
view.queryResults?.getResults()?.forEach((media: ViewMedia) => {
|
||||||
|
const mediaContentID = media.getContentID(cameras.get(media.getCameraID()));
|
||||||
|
if (this.hass && mediaContentID) {
|
||||||
|
promises.push(
|
||||||
|
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
},
|
},
|
||||||
() => [this.view?.target],
|
() => [this.viewerConfig, this.cameras, this.view],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,27 +254,8 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @param changedProperties The properties that were changed in this render.
|
* @param changedProperties The properties that were changed in this render.
|
||||||
*/
|
*/
|
||||||
updated(changedProperties: PropertyValues): void {
|
updated(changedProperties: PropertyValues): void {
|
||||||
const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel();
|
if (changedProperties.has('view')) {
|
||||||
|
|
||||||
if (frigateCardCarousel && changedProperties.has('view')) {
|
|
||||||
const oldView = changedProperties.get('view') as View | undefined;
|
const oldView = changedProperties.get('view') as View | undefined;
|
||||||
if (oldView) {
|
|
||||||
if (
|
|
||||||
oldView.target === this.view?.target &&
|
|
||||||
oldView.childIndex !== this.view.childIndex
|
|
||||||
) {
|
|
||||||
const slide = this._getSlideForChild(this.view.childIndex);
|
|
||||||
if (
|
|
||||||
slide !== null &&
|
|
||||||
slide !== frigateCardCarousel.getCarouselSelected()?.index
|
|
||||||
) {
|
|
||||||
// If the media target is the same as already loaded, but isn't of
|
|
||||||
// the selected slide, scroll to that slide.
|
|
||||||
frigateCardCarousel.carouselScrollTo(slide);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek into the video if the seek time has changed (this is also called
|
// Seek into the video if the seek time has changed (this is also called
|
||||||
// on media load, since the media may or may not have been loaded at
|
// on media load, since the media may or may not have been loaded at
|
||||||
// this point).
|
// this point).
|
||||||
@@ -282,21 +266,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the slide number given a media child number.
|
|
||||||
* @param childIndex The child index (relative to `view.target`)
|
|
||||||
* @returns A number or null if the child is not found.
|
|
||||||
*/
|
|
||||||
protected _getSlideForChild(childIndex: number | null | undefined): number | null {
|
|
||||||
if (childIndex === undefined || childIndex === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const slideIndex = Object.keys(this._slideToChild).find(
|
|
||||||
(key) => this._slideToChild[key] === childIndex,
|
|
||||||
);
|
|
||||||
return slideIndex !== undefined ? Number(slideIndex) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the transition effect to use.
|
* Get the transition effect to use.
|
||||||
* @returns An TransitionEffect object.
|
* @returns An TransitionEffect object.
|
||||||
@@ -308,18 +277,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Embla options to use.
|
|
||||||
* @returns An EmblaOptionsType object or undefined for no options.
|
|
||||||
*/
|
|
||||||
protected _getOptions(): EmblaOptionsType {
|
|
||||||
return {
|
|
||||||
// Start the carousel on the selected child number.
|
|
||||||
startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0,
|
|
||||||
draggable: this.viewerConfig?.draggable ?? true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The the HLS player on a slide (or current slide if not provided.)
|
* The the HLS player on a slide (or current slide if not provided.)
|
||||||
* @param slide An optional slide.
|
* @param slide An optional slide.
|
||||||
@@ -344,10 +301,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
protected _getPlugins(): EmblaPluginType[] {
|
protected _getPlugins(): EmblaPluginType[] {
|
||||||
return [
|
return [
|
||||||
// Only enable wheel plugin if there is more than one media item.
|
// Only enable wheel plugin if there is more than one media item.
|
||||||
...(this.view &&
|
...(this.view?.queryResults?.getResultsCount() ?? 0 > 1
|
||||||
this.view.target &&
|
|
||||||
this.view.target.children &&
|
|
||||||
this.view.target.children.length > 1
|
|
||||||
? [
|
? [
|
||||||
WheelGesturesPlugin({
|
WheelGesturesPlugin({
|
||||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||||
@@ -384,42 +338,20 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @returns A BrowseMediaNeighbors with indices and objects of true media
|
* @returns A BrowseMediaNeighbors with indices and objects of true media
|
||||||
* neighbors.
|
* neighbors.
|
||||||
*/
|
*/
|
||||||
protected _getMediaNeighbors(): BrowseMediaNeighbors | null {
|
protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] {
|
||||||
if (
|
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||||
!this.view ||
|
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
||||||
!this.view.target ||
|
if (!this.view || !this.view.queryResults || selectedIndex === null) {
|
||||||
!this.view.target.children ||
|
return [null, null];
|
||||||
this.view.childIndex === null
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work backwards from the index to get the previous real media.
|
const previous: ViewMedia | null =
|
||||||
let prevIndex: number | null = null;
|
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null;
|
||||||
for (let i = this.view.childIndex - 1; i >= 0; i--) {
|
const next: ViewMedia | null =
|
||||||
const media = this.view.target.children[i];
|
selectedIndex + 1 < resultCount
|
||||||
if (media && isTrueMedia(media)) {
|
? this.view.queryResults.getResult(selectedIndex + 1)
|
||||||
prevIndex = i;
|
: null;
|
||||||
break;
|
return [previous, next];
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Work forwards from the index to get the next real media.
|
|
||||||
let nextIndex: number | null = null;
|
|
||||||
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
|
|
||||||
const media = this.view.target.children[i];
|
|
||||||
if (media && isTrueMedia(media)) {
|
|
||||||
nextIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
previousIndex: prevIndex,
|
|
||||||
previous: prevIndex != null ? this.view.target.children[prevIndex] : null,
|
|
||||||
nextIndex: nextIndex,
|
|
||||||
next: nextIndex != null ? this.view.target.children[nextIndex] : null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -428,91 +360,55 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @param snapshot The snapshot to find a matching clip for.
|
* @param snapshot The snapshot to find a matching clip for.
|
||||||
* @returns The view that would show the matching clip.
|
* @returns The view that would show the matching clip.
|
||||||
*/
|
*/
|
||||||
protected async _findRelatedClipView(
|
protected async _createRelatedClipView(targetIndex: number): Promise<View | null> {
|
||||||
snapshot: FrigateBrowseMediaSource,
|
const media = this.view?.queryResults?.getResult(targetIndex);
|
||||||
): Promise<View | null> {
|
|
||||||
if (
|
if (
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.view ||
|
!this.view ||
|
||||||
!this.view.target ||
|
!media ||
|
||||||
!this.view.target.children ||
|
// If this specific media item has no clip, then do nothing (even if all
|
||||||
!this.view.target.children.length ||
|
// the other media items do).
|
||||||
!this.browseMediaQueryParameters
|
!ViewMediaClassifier.isFrigateEvent(media) ||
|
||||||
|
!media.hasClip() ||
|
||||||
|
!this.view.query?.areEventQueries()
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshotStartTime = getEventStartTime(snapshot);
|
const newResults: ViewMedia[] = [];
|
||||||
if (!snapshotStartTime) {
|
let newSelectedIndex: number | null = null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heuristic: At this point, the user has a particular snapshot that they
|
// Convert the query to a clips equivalent.
|
||||||
// are interested in and want to see a related clip, yet the viewer code
|
const newQuery = this.view.query.clone();
|
||||||
// does not know the exact search criteria that led to that snapshot (e.g.
|
newQuery.convertToClipsQueries();
|
||||||
// it could be a 10-deep folder in the gallery). To give the user to ability
|
|
||||||
// to 'navigate' in the clips view once they change into that mode, this
|
// Regenerate the whole results stack.
|
||||||
// heuristic finds the earliest and latest snapshot that the user is
|
for (let i = 0; i < (this.view.queryResults?.getResultsCount() ?? 0); ++i) {
|
||||||
// currently viewing and mirrors that range into the clips view. Then,
|
const media = this.view.queryResults?.getResult(i);
|
||||||
// within the results see if there's a clip that matches the same time as
|
if (!media || !ViewMediaClassifier.isFrigateEvent(media)) {
|
||||||
// the snapshot.
|
|
||||||
let earliest: number | null = null;
|
|
||||||
let latest: number | null = null;
|
|
||||||
for (let i = 0; i < this.view.target.children.length; i++) {
|
|
||||||
const child = this.view.target.children[i];
|
|
||||||
if (!isTrueMedia(child)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const startTime = getEventStartTime(child);
|
const clipMedia = media.getClipEquivalent();
|
||||||
|
if (clipMedia) {
|
||||||
if (startTime && (earliest === null || startTime < earliest)) {
|
newResults.push(clipMedia);
|
||||||
earliest = startTime;
|
if (i === targetIndex) {
|
||||||
}
|
newSelectedIndex = i;
|
||||||
if (startTime && (latest === null || startTime > latest)) {
|
}
|
||||||
latest = startTime;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!earliest || !latest) {
|
if (newSelectedIndex === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let clips: FrigateBrowseMediaSource | null;
|
const newQueryResults = new MediaQueriesResults(newResults);
|
||||||
|
newQueryResults.selectResult(newSelectedIndex);
|
||||||
|
|
||||||
const params = overrideMultiBrowseMediaQueryParameters(
|
return this.view.evolve({
|
||||||
this.browseMediaQueryParameters,
|
view: 'clip',
|
||||||
{
|
query: newQuery,
|
||||||
mediaType: 'clips',
|
queryResults: newQueryResults,
|
||||||
before: latest,
|
});
|
||||||
after: earliest,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
clips = await multipleBrowseMediaQueryMerged(this.hass, params);
|
|
||||||
} catch (e) {
|
|
||||||
// This is best effort.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clips || !clips.children || !clips.children.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < clips.children.length; i++) {
|
|
||||||
const child = clips.children[i];
|
|
||||||
if (!isTrueMedia(child)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const clipStartTime = getEventStartTime(child);
|
|
||||||
if (clipStartTime && clipStartTime === snapshotStartTime) {
|
|
||||||
return this.view.evolve({
|
|
||||||
view: 'clip',
|
|
||||||
target: clips,
|
|
||||||
childIndex: i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -523,13 +419,15 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the childIndex in the view.
|
// The slide may already be selected on load, so don't dispatch a new view
|
||||||
const childIndex = this._slideToChild[ev.detail.index];
|
// unless necessary.
|
||||||
if (childIndex !== undefined) {
|
if (ev.detail.index !== this.view.queryResults?.getSelectedIndex()) {
|
||||||
this.view
|
this.view
|
||||||
.evolve({
|
.evolve({
|
||||||
childIndex: childIndex,
|
queryResults: this.view.queryResults?.clone().selectResult(ev.detail.index),
|
||||||
})
|
})
|
||||||
|
// Ensure the timeline is able to update its position.
|
||||||
|
.mergeInContext({ timeline: { noSetWindow: false } })
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,11 +437,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* default location will be the Chromecast receiver, not HA).
|
* default location will be the Chromecast receiver, not HA).
|
||||||
* @param url The media URL
|
* @param url The media URL
|
||||||
*/
|
*/
|
||||||
protected _canonicalizeHAURL(url?: string): string | undefined {
|
protected _canonicalizeHAURL(url?: string): string | null {
|
||||||
if (this.hass && url && url.startsWith('/')) {
|
if (this.hass && url && url.startsWith('/')) {
|
||||||
return this.hass.hassUrl(url);
|
return this.hass.hassUrl(url);
|
||||||
}
|
}
|
||||||
return url;
|
return url ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -551,44 +449,40 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @param index The index of the slide to lazy load.
|
* @param index The index of the slide to lazy load.
|
||||||
* @param slide The slide to lazy load.
|
* @param slide The slide to lazy load.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
protected _lazyloadSlide(index: number, slide: HTMLElement): void {
|
protected _lazyloadSlide(index: number, slide: HTMLElement): void {
|
||||||
const childIndex: number | undefined = this._slideToChild[index];
|
if (!this.hass || !this.view || !this.view.query || !this.cameras) {
|
||||||
|
|
||||||
if (
|
|
||||||
childIndex === undefined ||
|
|
||||||
!this.hass ||
|
|
||||||
!this.view ||
|
|
||||||
!this.view.target ||
|
|
||||||
!this.view.target.children ||
|
|
||||||
!isTrueMedia(this.view.target.children[childIndex])
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveMedia(
|
const media = this.view.queryResults?.getResult(index);
|
||||||
this.hass,
|
const mediaContentID = media
|
||||||
this.view.target.children[childIndex],
|
? media.getContentID(this.cameras.get(media.getCameraID()))
|
||||||
this.resolvedMediaCache,
|
: null;
|
||||||
).then((resolvedMedia) => {
|
if (!mediaContentID) {
|
||||||
if (!resolvedMedia) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshots.
|
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(
|
||||||
const img = slide.querySelector('img') as HTMLImageElement;
|
(resolvedMedia) => {
|
||||||
|
if (!resolvedMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Frigate >= 0.9.0+ clips.
|
// Snapshots.
|
||||||
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
|
const img = slide.querySelector('img') as HTMLImageElement;
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (img) {
|
// Frigate >= 0.9.0+ clips.
|
||||||
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
|
||||||
} else if (hls_player) {
|
url: string;
|
||||||
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
};
|
||||||
}
|
|
||||||
});
|
if (img) {
|
||||||
|
img.src = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
|
||||||
|
} else if (hls_player) {
|
||||||
|
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -596,21 +490,18 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @returns The slides to include in the render.
|
* @returns The slides to include in the render.
|
||||||
*/
|
*/
|
||||||
protected _getSlides(): TemplateResult[] {
|
protected _getSlides(): TemplateResult[] {
|
||||||
if (
|
if (!this.view || !this.view.queryResults) {
|
||||||
!this.view ||
|
|
||||||
!this.view.target ||
|
|
||||||
!this.view.target.children ||
|
|
||||||
!this.view.target.children.length
|
|
||||||
) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const slides: TemplateResult[] = [];
|
const slides: TemplateResult[] = [];
|
||||||
for (let i = 0; i < this.view.target.children?.length; ++i) {
|
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
||||||
const slide = this._renderMediaItem(this.view.target.children[i], slides.length);
|
const media = this.view.queryResults.getResult(i);
|
||||||
|
if (media) {
|
||||||
if (slide) {
|
const slide = this._renderMediaItem(media, i);
|
||||||
slides.push(slide);
|
if (slide) {
|
||||||
|
slides[i] = slide;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slides;
|
return slides;
|
||||||
@@ -620,8 +511,12 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* Determine if all the media in the carousel are resolved.
|
* Determine if all the media in the carousel are resolved.
|
||||||
*/
|
*/
|
||||||
protected _isMediaFullyResolved(): boolean {
|
protected _isMediaFullyResolved(): boolean {
|
||||||
for (const child of this.view?.target?.children || []) {
|
if (!this.resolvedMediaCache || !this.cameras) {
|
||||||
if (!this.resolvedMediaCache?.has(child.media_content_id)) {
|
return false;
|
||||||
|
}
|
||||||
|
for (const media of this.view?.queryResults?.getResults() ?? []) {
|
||||||
|
const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID()));
|
||||||
|
if (mediaContentID && !this.resolvedMediaCache.has(mediaContentID)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,29 +528,20 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @param changedProps The changed properties
|
* @param changedProps The changed properties
|
||||||
*/
|
*/
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
// Pre-populate a map between real media slides and view child indicies.
|
|
||||||
if (changedProps.has('view')) {
|
|
||||||
this._slideToChild = {};
|
|
||||||
let i = 0;
|
|
||||||
(this.view?.target?.children ?? []).forEach((child, index) => {
|
|
||||||
if (isTrueMedia(child) && ['video', 'image'].includes(child.media_content_type)) {
|
|
||||||
this._slideToChild[i++] = index;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changedProps.has('viewerConfig')) {
|
if (changedProps.has('viewerConfig')) {
|
||||||
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
|
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
|
||||||
}
|
}
|
||||||
if (!this._carouselOptions || changedProps.has('viewerConfig')) {
|
if (!this._carouselOptions || changedProps.has('viewerConfig')) {
|
||||||
this._carouselOptions = this._getOptions();
|
this._carouselOptions = {
|
||||||
|
draggable: this.viewerConfig?.draggable ?? true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!this._carouselPlugins ||
|
!this._carouselPlugins ||
|
||||||
changedProps.has('viewerConfig') ||
|
changedProps.has('viewerConfig') ||
|
||||||
(changedProps.has('view') &&
|
(changedProps.has('view') &&
|
||||||
this.view?.target?.children?.length !==
|
this.view?.queryResults?.getResultsCount() !==
|
||||||
changedProps.get('view')?.target?.children?.length)
|
changedProps.get('view')?.queryResults?.getResultsCount())
|
||||||
) {
|
) {
|
||||||
this._carouselPlugins = this._getPlugins();
|
this._carouselPlugins = this._getPlugins();
|
||||||
}
|
}
|
||||||
@@ -680,21 +566,20 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @returns A template to display to the user.
|
* @returns A template to display to the user.
|
||||||
*/
|
*/
|
||||||
protected _render(): TemplateResult | void {
|
protected _render(): TemplateResult | void {
|
||||||
const slides = this._getSlides();
|
const media = this.view?.queryResults?.getSelectedResult();
|
||||||
|
if (!media || !this.cameras) {
|
||||||
if (!slides.length || !this.view?.media) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const neighbors = this._getMediaNeighbors();
|
const [prev, next] = this._getMediaNeighbors();
|
||||||
const [prev, next] = [neighbors?.previous, neighbors?.next];
|
|
||||||
|
|
||||||
return html` <frigate-card-media-carousel
|
return html` <frigate-card-media-carousel
|
||||||
${ref(this._refMediaCarousel)}
|
${ref(this._refMediaCarousel)}
|
||||||
.carouselOptions=${this._carouselOptions}
|
.carouselOptions=${this._carouselOptions}
|
||||||
.carouselPlugins=${this._carouselPlugins}
|
.carouselPlugins=${this._carouselPlugins}
|
||||||
.label="${this.view.media.title}"
|
.label=${media.getTitle() ?? undefined}
|
||||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
||||||
|
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||||
@frigate-card:media:loaded=${this._recordingSeekHandler.bind(this)}
|
@frigate-card:media:loaded=${this._recordingSeekHandler.bind(this)}
|
||||||
@@ -704,22 +589,24 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'previous'}
|
.direction=${'previous'}
|
||||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||||
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
|
.thumbnail=${prev?.getThumbnail(this.cameras.get(prev.getCameraID())) ??
|
||||||
.label=${prev ? prev.title : ''}
|
undefined}
|
||||||
|
.label=${prev?.getTitle() ?? ''}
|
||||||
?disabled=${!prev}
|
?disabled=${!prev}
|
||||||
@click=${(ev) => {
|
@click=${(ev) => {
|
||||||
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
|
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
></frigate-card-next-previous-control>
|
></frigate-card-next-previous-control>
|
||||||
${slides}
|
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
|
||||||
<frigate-card-next-previous-control
|
<frigate-card-next-previous-control
|
||||||
slot="next"
|
slot="next"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'next'}
|
.direction=${'next'}
|
||||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||||
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
|
.thumbnail=${next?.getThumbnail(this.cameras.get(next.getCameraID())) ??
|
||||||
.label=${next ? next.title : ''}
|
undefined}
|
||||||
|
.label=${next?.getTitle() ?? ''}
|
||||||
?disabled=${!next}
|
?disabled=${!next}
|
||||||
@click=${(ev) => {
|
@click=${(ev) => {
|
||||||
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
|
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
|
||||||
@@ -733,10 +620,12 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* Fire a media show event when a slide is selected.
|
* Fire a media show event when a slide is selected.
|
||||||
*/
|
*/
|
||||||
protected _recordingSeekHandler(): void {
|
protected _recordingSeekHandler(): void {
|
||||||
const player = this._getPlayer();
|
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||||
const childIndex = this.view?.childIndex ?? null;
|
|
||||||
const seek =
|
const seek =
|
||||||
childIndex !== null ? this.view?.context?.mediaViewer?.seek.get(childIndex) : null;
|
selectedIndex !== null
|
||||||
|
? this.view?.context?.mediaViewer?.seek.get(selectedIndex)
|
||||||
|
: null;
|
||||||
|
const player = this._getPlayer();
|
||||||
if (player && seek) {
|
if (player && seek) {
|
||||||
player.seek(seek.seekSeconds);
|
player.seek(seek.seekSeconds);
|
||||||
}
|
}
|
||||||
@@ -744,59 +633,53 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a single media item in the viewer carousel.
|
* Render a single media item in the viewer carousel.
|
||||||
* @param mediaToRender The FrigateBrowseMediaSource to render.
|
* @param media The ViewMedia to render.
|
||||||
* @param slideIndex The index of the slide to render.
|
* @param index The (slide|queryResult) index of the item to render.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected _renderMediaItem(
|
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
|
||||||
mediaToRender: FrigateBrowseMediaSource,
|
|
||||||
slideIndex: number,
|
|
||||||
): TemplateResult | void {
|
|
||||||
// Skip folders as they cannot be rendered by this viewer.
|
// Skip folders as they cannot be rendered by this viewer.
|
||||||
if (
|
if (!this.hass || !this.view || !this.viewerConfig || !this.cameras) {
|
||||||
!this.hass ||
|
return null;
|
||||||
!this.view ||
|
|
||||||
!this.viewerConfig ||
|
|
||||||
!isTrueMedia(mediaToRender) ||
|
|
||||||
!['video', 'image'].includes(mediaToRender.media_content_type)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lazyLoad = this.viewerConfig.lazy_load;
|
const lazyLoad = this.viewerConfig.lazy_load;
|
||||||
const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id);
|
const mediaContentID = media.getContentID(this.cameras.get(media.getCameraID()));
|
||||||
|
const resolvedMedia = mediaContentID
|
||||||
|
? this.resolvedMediaCache?.get(mediaContentID)
|
||||||
|
: null;
|
||||||
if (!resolvedMedia && !lazyLoad) {
|
if (!resolvedMedia && !lazyLoad) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The media is attached to the player as '.media' which is used in
|
// The media is attached to the player as '.media' which is used in
|
||||||
// `_selectSlideMediaShowHandler` (and not used by the player itself).
|
// `_selectSlideMediaShowHandler` (and not used by the player itself).
|
||||||
return html`
|
return html`
|
||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
${mediaToRender.media_content_type === 'video'
|
${media.isVideo()
|
||||||
? html`<frigate-card-ha-hls-player
|
? html`<frigate-card-ha-hls-player
|
||||||
allow-exoplayer
|
allow-exoplayer
|
||||||
aria-label="${mediaToRender.title}"
|
aria-label="${media.getTitle() ?? ''}"
|
||||||
?autoplay=${false}
|
?autoplay=${false}
|
||||||
controls
|
controls
|
||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
title="${mediaToRender.title}"
|
title="${media.getTitle() ?? ''}"
|
||||||
url=${ifDefined(
|
url=${ifDefined(
|
||||||
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url),
|
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url) ?? '',
|
||||||
)}
|
)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
|
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
|
||||||
wrapMediaLoadedEventForCarousel(slideIndex, e);
|
wrapMediaLoadedEventForCarousel(index, e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-ha-hls-player>`
|
</frigate-card-ha-hls-player>`
|
||||||
: html`<img
|
: html`<img
|
||||||
aria-label="${mediaToRender.title}"
|
aria-label="${media.getTitle() ?? ''}"
|
||||||
src=${ifDefined(
|
src=${ifDefined(
|
||||||
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url),
|
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url) ?? '',
|
||||||
)}
|
)}
|
||||||
title="${mediaToRender.title}"
|
title="${media.getTitle() ?? ''}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
if (
|
if (
|
||||||
this._refMediaCarousel.value
|
this._refMediaCarousel.value
|
||||||
@@ -804,7 +687,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
?.carouselClickAllowed() &&
|
?.carouselClickAllowed() &&
|
||||||
this.viewerConfig?.snapshot_click_plays_clip
|
this.viewerConfig?.snapshot_click_plays_clip
|
||||||
) {
|
) {
|
||||||
this._findRelatedClipView(mediaToRender).then((view) => {
|
this._createRelatedClipView(index).then((view) => {
|
||||||
if (view) {
|
if (view) {
|
||||||
view.dispatchChangeEvent(this);
|
view.dispatchChangeEvent(this);
|
||||||
}
|
}
|
||||||
@@ -822,9 +705,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
// images in media-carousel.ts). Here we need to only call the
|
// images in media-carousel.ts). Here we need to only call the
|
||||||
// media load handler on a 'real' load.
|
// media load handler on a 'real' load.
|
||||||
!lazyLoad ||
|
!lazyLoad ||
|
||||||
lazyloadPlugin?.hasLazyloaded(slideIndex)
|
lazyloadPlugin?.hasLazyloaded(index)
|
||||||
) {
|
) {
|
||||||
wrapRawMediaLoadedEventForCarousel(slideIndex, e);
|
wrapRawMediaLoadedEventForCarousel(index, e);
|
||||||
}
|
}
|
||||||
}}"
|
}}"
|
||||||
/>`}
|
/>`}
|
||||||
|
|||||||
@@ -333,7 +333,6 @@
|
|||||||
"could_not_render_elements": "Could not render picture elements",
|
"could_not_render_elements": "Could not render picture elements",
|
||||||
"could_not_resolve": "Could not resolve media URL",
|
"could_not_resolve": "Could not resolve media URL",
|
||||||
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
|
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
|
||||||
"download_no_event_id": "Could not extract Frigate event id from media",
|
|
||||||
"download_no_media": "No media to download",
|
"download_no_media": "No media to download",
|
||||||
"download_sign_failed": "Could not sign media URL for download",
|
"download_sign_failed": "Could not sign media URL for download",
|
||||||
"duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
|
"duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
|
||||||
|
|||||||
@@ -303,7 +303,6 @@
|
|||||||
"could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
|
"could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
|
||||||
"could_not_resolve": "Impossibile risolvere l'URL dei media",
|
"could_not_resolve": "Impossibile risolvere l'URL dei media",
|
||||||
"diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere",
|
"diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere",
|
||||||
"download_no_event_id": "Impossibile estrarre l'evento ID tramite media",
|
|
||||||
"download_no_media": "Nessun media da scaricare",
|
"download_no_media": "Nessun media da scaricare",
|
||||||
"download_sign_failed": "Impossibile firmare URL multimediale per il download",
|
"download_sign_failed": "Impossibile firmare URL multimediale per il download",
|
||||||
"duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere",
|
"duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere",
|
||||||
|
|||||||
@@ -303,7 +303,6 @@
|
|||||||
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
|
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
|
||||||
"could_not_resolve": "Não foi possível resolver o URL de mídia",
|
"could_not_resolve": "Não foi possível resolver o URL de mídia",
|
||||||
"diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar",
|
"diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar",
|
||||||
"download_no_event_id": "Não foi possível extrair o Frigate ID do evento da mídia",
|
|
||||||
"download_no_media": "Nenhuma mídia para download",
|
"download_no_media": "Nenhuma mídia para download",
|
||||||
"download_sign_failed": "Não foi possível assinar o URL de mídia para download",
|
"download_sign_failed": "Não foi possível assinar o URL de mídia para download",
|
||||||
"duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras",
|
"duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras",
|
||||||
|
|||||||
+43
-74
@@ -27,6 +27,7 @@ export const THUMBNAIL_WIDTH_MIN = 75;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type ClipsOrSnapshots = 'clips' | 'snapshots';
|
export type ClipsOrSnapshots = 'clips' | 'snapshots';
|
||||||
|
export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all';
|
||||||
|
|
||||||
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
||||||
'live',
|
'live',
|
||||||
@@ -661,13 +662,30 @@ export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
|||||||
/**
|
/**
|
||||||
* Thumbnail controls configuration section.
|
* Thumbnail controls configuration section.
|
||||||
*/
|
*/
|
||||||
|
const thumbnailControlsDefaults = {
|
||||||
|
mode: 'right' as const,
|
||||||
|
size: 100,
|
||||||
|
show_details: true,
|
||||||
|
show_favorite_control: true,
|
||||||
|
show_timeline_control: true,
|
||||||
|
};
|
||||||
|
|
||||||
const thumbnailsControlSchema = z.object({
|
const thumbnailsControlSchema = z.object({
|
||||||
mode: z.enum(['none', 'above', 'below', 'left', 'right']),
|
mode: z
|
||||||
size: z.number().min(THUMBNAIL_WIDTH_MIN).max(THUMBNAIL_WIDTH_MAX).optional(),
|
.enum(['none', 'above', 'below', 'left', 'right'])
|
||||||
show_details: z.boolean().optional(),
|
.default(thumbnailControlsDefaults.mode),
|
||||||
show_favorite_control: z.boolean().optional(),
|
size: z
|
||||||
show_timeline_control: z.boolean().optional(),
|
.number()
|
||||||
|
.min(THUMBNAIL_WIDTH_MIN)
|
||||||
|
.max(THUMBNAIL_WIDTH_MAX)
|
||||||
|
.default(thumbnailControlsDefaults.size),
|
||||||
|
show_details: z.boolean().default(thumbnailControlsDefaults.show_details),
|
||||||
|
show_favorite_control: z
|
||||||
|
.boolean()
|
||||||
|
.default(thumbnailControlsDefaults.show_favorite_control),
|
||||||
|
show_timeline_control: z
|
||||||
|
.boolean()
|
||||||
|
.default(thumbnailControlsDefaults.show_timeline_control),
|
||||||
});
|
});
|
||||||
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
|
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
|
||||||
|
|
||||||
@@ -752,6 +770,11 @@ const liveImageConfigDefault = {
|
|||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const liveThumbnailControlsDefaults = {
|
||||||
|
...thumbnailControlsDefaults,
|
||||||
|
media: 'clips' as const,
|
||||||
|
};
|
||||||
|
|
||||||
const liveConfigDefault = {
|
const liveConfigDefault = {
|
||||||
auto_play: 'all' as const,
|
auto_play: 'all' as const,
|
||||||
auto_pause: 'never' as const,
|
auto_pause: 'never' as const,
|
||||||
@@ -769,14 +792,7 @@ const liveConfigDefault = {
|
|||||||
size: 48,
|
size: 48,
|
||||||
style: 'chevrons' as const,
|
style: 'chevrons' as const,
|
||||||
},
|
},
|
||||||
thumbnails: {
|
thumbnails: liveThumbnailControlsDefaults,
|
||||||
media: 'clips' as const,
|
|
||||||
size: 100,
|
|
||||||
show_details: true,
|
|
||||||
show_favorite_control: true,
|
|
||||||
show_timeline_control: true,
|
|
||||||
mode: 'left' as const,
|
|
||||||
},
|
|
||||||
timeline: miniTimelineConfigDefault,
|
timeline: miniTimelineConfigDefault,
|
||||||
title: {
|
title: {
|
||||||
mode: 'popup-bottom-right' as const,
|
mode: 'popup-bottom-right' as const,
|
||||||
@@ -785,6 +801,12 @@ const liveConfigDefault = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
||||||
|
media: z
|
||||||
|
.enum(['clips', 'snapshots'])
|
||||||
|
.default(liveConfigDefault.controls.thumbnails.media),
|
||||||
|
});
|
||||||
|
|
||||||
const liveImageConfigSchema = z.object({
|
const liveImageConfigSchema = z.object({
|
||||||
refresh_seconds: z.number().min(0).default(liveConfigDefault.image.refresh_seconds),
|
refresh_seconds: z.number().min(0).default(liveConfigDefault.image.refresh_seconds),
|
||||||
});
|
});
|
||||||
@@ -834,30 +856,9 @@ const liveOverridableConfigSchema = z
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
.default(liveConfigDefault.controls.next_previous),
|
.default(liveConfigDefault.controls.next_previous),
|
||||||
thumbnails: thumbnailsControlSchema
|
thumbnails: livethumbnailsControlSchema.default(
|
||||||
.extend({
|
liveConfigDefault.controls.thumbnails,
|
||||||
mode: thumbnailsControlSchema.shape.mode.default(
|
),
|
||||||
liveConfigDefault.controls.thumbnails.mode,
|
|
||||||
),
|
|
||||||
size: thumbnailsControlSchema.shape.size.default(
|
|
||||||
liveConfigDefault.controls.thumbnails.size,
|
|
||||||
),
|
|
||||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
|
||||||
liveConfigDefault.controls.thumbnails.show_details,
|
|
||||||
),
|
|
||||||
show_favorite_control:
|
|
||||||
thumbnailsControlSchema.shape.show_favorite_control.default(
|
|
||||||
liveConfigDefault.controls.thumbnails.show_favorite_control,
|
|
||||||
),
|
|
||||||
show_timeline_control:
|
|
||||||
thumbnailsControlSchema.shape.show_timeline_control.default(
|
|
||||||
liveConfigDefault.controls.thumbnails.show_timeline_control,
|
|
||||||
),
|
|
||||||
media: z
|
|
||||||
.enum(['clips', 'snapshots'])
|
|
||||||
.default(liveConfigDefault.controls.thumbnails.media),
|
|
||||||
})
|
|
||||||
.default(liveConfigDefault.controls.thumbnails),
|
|
||||||
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
||||||
title: titleControlConfigSchema
|
title: titleControlConfigSchema
|
||||||
.extend({
|
.extend({
|
||||||
@@ -994,13 +995,7 @@ const viewerConfigDefault = {
|
|||||||
size: 48,
|
size: 48,
|
||||||
style: 'thumbnails' as const,
|
style: 'thumbnails' as const,
|
||||||
},
|
},
|
||||||
thumbnails: {
|
thumbnails: thumbnailControlsDefaults,
|
||||||
size: 100,
|
|
||||||
show_details: true,
|
|
||||||
show_favorite_control: true,
|
|
||||||
show_timeline_control: true,
|
|
||||||
mode: 'left' as const,
|
|
||||||
},
|
|
||||||
timeline: miniTimelineConfigDefault,
|
timeline: miniTimelineConfigDefault,
|
||||||
title: {
|
title: {
|
||||||
mode: 'popup-bottom-right' as const,
|
mode: 'popup-bottom-right' as const,
|
||||||
@@ -1047,27 +1042,9 @@ const viewerConfigSchema = z
|
|||||||
next_previous: viewerNextPreviousControlConfigSchema.default(
|
next_previous: viewerNextPreviousControlConfigSchema.default(
|
||||||
viewerConfigDefault.controls.next_previous,
|
viewerConfigDefault.controls.next_previous,
|
||||||
),
|
),
|
||||||
thumbnails: thumbnailsControlSchema
|
thumbnails: thumbnailsControlSchema.default(
|
||||||
.extend({
|
viewerConfigDefault.controls.thumbnails,
|
||||||
mode: thumbnailsControlSchema.shape.mode.default(
|
),
|
||||||
viewerConfigDefault.controls.thumbnails.mode,
|
|
||||||
),
|
|
||||||
size: thumbnailsControlSchema.shape.size.default(
|
|
||||||
viewerConfigDefault.controls.thumbnails.size,
|
|
||||||
),
|
|
||||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
|
||||||
viewerConfigDefault.controls.thumbnails.show_details,
|
|
||||||
),
|
|
||||||
show_favorite_control:
|
|
||||||
thumbnailsControlSchema.shape.show_favorite_control.default(
|
|
||||||
viewerConfigDefault.controls.thumbnails.show_favorite_control,
|
|
||||||
),
|
|
||||||
show_timeline_control:
|
|
||||||
thumbnailsControlSchema.shape.show_timeline_control.default(
|
|
||||||
viewerConfigDefault.controls.thumbnails.show_timeline_control,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.default(viewerConfigDefault.controls.thumbnails),
|
|
||||||
timeline: miniTimelineConfigSchema.default(
|
timeline: miniTimelineConfigSchema.default(
|
||||||
viewerConfigDefault.controls.timeline,
|
viewerConfigDefault.controls.timeline,
|
||||||
),
|
),
|
||||||
@@ -1364,14 +1341,6 @@ export interface BrowseRecordingQueryParameters {
|
|||||||
hour: number;
|
hour: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowseMediaNeighbors {
|
|
||||||
previous: FrigateBrowseMediaSource | null;
|
|
||||||
previousIndex: number | null;
|
|
||||||
|
|
||||||
next: FrigateBrowseMediaSource | null;
|
|
||||||
nextIndex: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaLoadedInfo {
|
export interface MediaLoadedInfo {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -1434,7 +1403,7 @@ export const MEDIA_TYPE_VIDEO = 'video' as const;
|
|||||||
// See: https://github.com/colinhacks/zod#recursive-types
|
// See: https://github.com/colinhacks/zod#recursive-types
|
||||||
//
|
//
|
||||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L46
|
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L46
|
||||||
interface BrowseMediaSource {
|
export interface BrowseMediaSource {
|
||||||
title: string;
|
title: string;
|
||||||
media_class: string;
|
media_class: string;
|
||||||
media_content_type: string;
|
media_content_type: string;
|
||||||
|
|||||||
+54
-10
@@ -1,7 +1,12 @@
|
|||||||
|
import differenceInHours from 'date-fns/differenceInHours';
|
||||||
|
import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||||
|
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import isEqual from 'lodash-es/isEqual';
|
||||||
import { FrigateCardError } from '../types';
|
import { FrigateCardError } from '../types';
|
||||||
|
|
||||||
|
export type ModifyInterface<T, R> = Omit<T, keyof R> & R;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch a Frigate Card event.
|
* Dispatch a Frigate Card event.
|
||||||
* @param element The element to send the event.
|
* @param element The element to send the event.
|
||||||
@@ -51,6 +56,24 @@ export function arrayMove(target: unknown[], from: number, to: number): void {
|
|||||||
target.splice(to, 0, element);
|
target.splice(to, 0, element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a value to an array if it is not already one.
|
||||||
|
* @param value: A value (which may be an array).
|
||||||
|
* @returns An array.
|
||||||
|
*/
|
||||||
|
export const arrayify = <T>(value: T | T[]): T[] => {
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a value to an set if it is not already one.
|
||||||
|
* @param value: A value (which may be a set, an array or a T)
|
||||||
|
* @returns A set of T.
|
||||||
|
*/
|
||||||
|
export const setify = <T>(value: T | T[] | Set<T>): Set<T> => {
|
||||||
|
return value instanceof Set ? value : new Set(arrayify(value));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the contents of the n(ew) and o(ld) values have changed. For use
|
* Determine if the contents of the n(ew) and o(ld) values have changed. For use
|
||||||
* in lit web components that may have a value that changes address but not
|
* in lit web components that may have a value that changes address but not
|
||||||
@@ -68,10 +91,7 @@ export function contentsChanged(n: unknown, o: unknown): boolean {
|
|||||||
* @param e The Error object.
|
* @param e The Error object.
|
||||||
* @param func The Console func to call.
|
* @param func The Console func to call.
|
||||||
*/
|
*/
|
||||||
export function errorToConsole(e: Error, func?: CallableFunction): void {
|
export function errorToConsole(e: Error, func: CallableFunction = console.warn): void {
|
||||||
if (!func) {
|
|
||||||
func = console.warn;
|
|
||||||
}
|
|
||||||
if (e instanceof FrigateCardError && e.context) {
|
if (e instanceof FrigateCardError && e.context) {
|
||||||
func(e, e.context);
|
func(e, e.context);
|
||||||
} else {
|
} else {
|
||||||
@@ -83,9 +103,8 @@ export function errorToConsole(e: Error, func?: CallableFunction): void {
|
|||||||
* Determine if the device supports hovering.
|
* Determine if the device supports hovering.
|
||||||
* @returns `true` if the device supports hovering, `false` otherwise.
|
* @returns `true` if the device supports hovering, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
export const isHoverableDevice = (): boolean => window.matchMedia(
|
export const isHoverableDevice = (): boolean =>
|
||||||
'(hover: hover) and (pointer: fine)',
|
window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||||
).matches;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a date object to RFC3339.
|
* Format a date object to RFC3339.
|
||||||
@@ -94,7 +113,7 @@ export const isHoverableDevice = (): boolean => window.matchMedia(
|
|||||||
*/
|
*/
|
||||||
export const formatDateAndTime = (date: Date): string => {
|
export const formatDateAndTime = (date: Date): string => {
|
||||||
return format(date, 'yyyy-MM-dd HH:mm');
|
return format(date, 'yyyy-MM-dd HH:mm');
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a function in idle periods. If idle callbacks are not supported (e.g.
|
* Run a function in idle periods. If idle callbacks are not supported (e.g.
|
||||||
@@ -105,9 +124,34 @@ export const formatDateAndTime = (date: Date): string => {
|
|||||||
export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => {
|
export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => {
|
||||||
if (window.requestIdleCallback) {
|
if (window.requestIdleCallback) {
|
||||||
window.requestIdleCallback(func, {
|
window.requestIdleCallback(func, {
|
||||||
...(timeout && { timeout: timeout})
|
...(timeout && { timeout: timeout }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
func();
|
func();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to return a string representing the difference in hours,
|
||||||
|
* minutes and seconds between two dates. Heavily inspired by, and returning the
|
||||||
|
* same format as, the Frigate UI:
|
||||||
|
* https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
||||||
|
* @param start The start date.
|
||||||
|
* @param end The end date.
|
||||||
|
* @returns A duration string.
|
||||||
|
*/
|
||||||
|
export function getDurationString(start: Date, end: Date): string {
|
||||||
|
const hours = differenceInHours(end, start);
|
||||||
|
const minutes = differenceInMinutes(end, start) - hours * 60;
|
||||||
|
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
||||||
|
let duration = '';
|
||||||
|
|
||||||
|
if (hours) {
|
||||||
|
duration += `${hours}h `;
|
||||||
|
}
|
||||||
|
if (minutes) {
|
||||||
|
duration += `${minutes}m `;
|
||||||
|
}
|
||||||
|
duration += `${seconds}s`;
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|||||||
+6
-37
@@ -81,13 +81,13 @@ export function getCameraIcon(
|
|||||||
*/
|
*/
|
||||||
export const getAllDependentCameras = (
|
export const getAllDependentCameras = (
|
||||||
cameras: Map<string, CameraConfig>,
|
cameras: Map<string, CameraConfig>,
|
||||||
camera?: string,
|
cameraID?: string,
|
||||||
): Set<string> => {
|
): Set<string> => {
|
||||||
const cameraIDs: Set<string> = new Set();
|
const cameraIDs: Set<string> = new Set();
|
||||||
const getDependentCameras = (camera: string): void => {
|
const getDependentCameras = (cameraID: string): void => {
|
||||||
const cameraConfig = cameras.get(camera);
|
const cameraConfig = cameras.get(cameraID);
|
||||||
if (cameraConfig) {
|
if (cameraConfig) {
|
||||||
cameraIDs.add(camera);
|
cameraIDs.add(cameraID);
|
||||||
const dependentCameras: Set<string> = new Set();
|
const dependentCameras: Set<string> = new Set();
|
||||||
(cameraConfig.dependencies.cameras || []).forEach((item) =>
|
(cameraConfig.dependencies.cameras || []).forEach((item) =>
|
||||||
dependentCameras.add(item),
|
dependentCameras.add(item),
|
||||||
@@ -102,39 +102,8 @@ export const getAllDependentCameras = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (camera) {
|
if (cameraID) {
|
||||||
getDependentCameras(camera);
|
getDependentCameras(cameraID);
|
||||||
}
|
}
|
||||||
return cameraIDs;
|
return cameraIDs;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the cameraIDs of truly unique cameras (some configured cameras may be
|
|
||||||
* the same Frigate came but with different zone/labels).
|
|
||||||
* @param cameras The full set of cameras.
|
|
||||||
* @param cameraIDs The specific IDs to dedup.
|
|
||||||
*/
|
|
||||||
export const getTrueCameras = (
|
|
||||||
cameras: Map<string, CameraConfig>,
|
|
||||||
cameraIDs: Set<string>,
|
|
||||||
): Set<string> => {
|
|
||||||
const getTrueCameraID = (cameraConfig: CameraConfig): string => {
|
|
||||||
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
|
||||||
cameraConfig.frigate.camera_name ?? ''
|
|
||||||
}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const output = new Set<string>();
|
|
||||||
const visitedTrueCameras = new Set<string>();
|
|
||||||
cameraIDs.forEach((cameraID: string) => {
|
|
||||||
const cameraConfig = cameras.get(cameraID) ?? null;
|
|
||||||
if (cameraConfig && cameraConfig.frigate.camera_name) {
|
|
||||||
const trueCameraID = getTrueCameraID(cameraConfig);
|
|
||||||
if (!visitedTrueCameras.has(trueCameraID)) {
|
|
||||||
output.add(cameraID);
|
|
||||||
visitedTrueCameras.add(trueCameraID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,540 +0,0 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
|
||||||
import { DataSet, DataView } from 'vis-data/esnext';
|
|
||||||
import type { IdType, TimelineItem } from 'vis-timeline/esnext';
|
|
||||||
import { CAMERA_BIRDSEYE } from '../const.js';
|
|
||||||
import {
|
|
||||||
CameraConfig,
|
|
||||||
ExtendedHomeAssistant,
|
|
||||||
FrigateCardError,
|
|
||||||
FrigateEvent,
|
|
||||||
FrigateEvents,
|
|
||||||
} from '../types.js';
|
|
||||||
import { errorToConsole, runWhenIdleIfSupported } from './basic.js';
|
|
||||||
import {
|
|
||||||
FrigateGetEventsParameters,
|
|
||||||
getEventsMultiple,
|
|
||||||
getRecordingSegments,
|
|
||||||
getRecordingsSummary,
|
|
||||||
RecordingSegments,
|
|
||||||
RecordingSummary,
|
|
||||||
} from './frigate.js';
|
|
||||||
import { dispatchFrigateCardErrorEvent } from '../components/message.js';
|
|
||||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
|
||||||
import throttle from 'lodash-es/throttle';
|
|
||||||
|
|
||||||
const RECORDING_SEGMENT_TOLERANCE = 60;
|
|
||||||
const DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
|
||||||
const DATA_MANAGER_MAX_FETCH_COUNT = 10000;
|
|
||||||
|
|
||||||
export interface FrigateCardTimelineItem extends TimelineItem {
|
|
||||||
// DataView has issues using datasets with Date objects, so avoid them and use
|
|
||||||
// numbers instead.
|
|
||||||
start: number;
|
|
||||||
end?: number;
|
|
||||||
event?: FrigateEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
|
|
||||||
|
|
||||||
export interface RecordingSegmentsItem {
|
|
||||||
id: string;
|
|
||||||
cameraID: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort the timeline items most recent to least recent.
|
|
||||||
* @param a The first item.
|
|
||||||
* @param b The second item.
|
|
||||||
* @returns -1, 0, 1 (standard array sort function configuration).
|
|
||||||
*/
|
|
||||||
export const sortYoungestToOldest = (
|
|
||||||
a: RecordingSegmentsItem | FrigateCardTimelineItem,
|
|
||||||
b: RecordingSegmentsItem | FrigateCardTimelineItem,
|
|
||||||
): number => {
|
|
||||||
if (a.start < b.start) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (a.start > b.start) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort the segments least recent to most recent.
|
|
||||||
* @param a The first item.
|
|
||||||
* @param b The second item.
|
|
||||||
* @returns -1, 0, 1 (standard array sort function configuration).
|
|
||||||
*/
|
|
||||||
export const sortOldestToYoungest = (
|
|
||||||
a: RecordingSegmentsItem | FrigateCardTimelineItem,
|
|
||||||
b: RecordingSegmentsItem | FrigateCardTimelineItem,
|
|
||||||
): number => {
|
|
||||||
if (a.start < b.start) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.start > b.start) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A manager to maintain/fetch timeline events.
|
|
||||||
*/
|
|
||||||
export class DataManager {
|
|
||||||
protected _recordingSummary: Map<string, RecordingSummary | null> = new Map();
|
|
||||||
protected _recordingSegments = new DataSet<RecordingSegmentsItem>();
|
|
||||||
|
|
||||||
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
|
||||||
|
|
||||||
// The earliest date managed.
|
|
||||||
protected _dateStart: Date | null = null;
|
|
||||||
|
|
||||||
// The latest date managed.
|
|
||||||
protected _dateEnd: Date | null = null;
|
|
||||||
|
|
||||||
// The last fetch date.
|
|
||||||
protected _dateFetch: Date | null = null;
|
|
||||||
|
|
||||||
// The maximum allowable age of fetch data (will not fetch more frequently
|
|
||||||
// than this).
|
|
||||||
protected _maxAgeSeconds: number = DATA_MANAGER_MAX_AGE_SECONDS;
|
|
||||||
|
|
||||||
protected _cameras: Map<string, CameraConfig>;
|
|
||||||
|
|
||||||
// Garbage collect segments at most once an hour.
|
|
||||||
protected _throttledSegmentGarbageCollector = throttle(
|
|
||||||
() => {
|
|
||||||
runWhenIdleIfSupported(this._garbageCollectSegments.bind(this));
|
|
||||||
},
|
|
||||||
60 * 60 * 1000,
|
|
||||||
{ trailing: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
constructor(cameras: Map<string, CameraConfig>) {
|
|
||||||
this._cameras = cameras;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the last event fetch date.
|
|
||||||
get lastFetchDate(): Date | null {
|
|
||||||
return this._dateFetch ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getRecordingSummaryForCamera(cameraID: string): RecordingSummary | null {
|
|
||||||
return this._recordingSummary.get(cameraID) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a dataview for a given set of camera.
|
|
||||||
* @param cameraIDs The cameraIDs to include.
|
|
||||||
* @param showRecordings Whether or not to show recordings.
|
|
||||||
* @returns A dataview.
|
|
||||||
*/
|
|
||||||
public createDataView(
|
|
||||||
cameraIDs: Set<string>,
|
|
||||||
showRecordings: boolean,
|
|
||||||
mediaType: TimelineMediaType,
|
|
||||||
): DataView<FrigateCardTimelineItem> {
|
|
||||||
return new DataView(this._dataset, {
|
|
||||||
filter: (item: FrigateCardTimelineItem) =>
|
|
||||||
// Only return items for the given cameras.
|
|
||||||
!!item.group &&
|
|
||||||
cameraIDs.has(String(item.group)) &&
|
|
||||||
// Don't return recordings if the user does not want them.
|
|
||||||
(showRecordings || item.type !== 'background') &&
|
|
||||||
// Don't return events that are the wrong media type.
|
|
||||||
(item.type === 'background' ||
|
|
||||||
mediaType === 'all' ||
|
|
||||||
(mediaType === 'clips' && !!item.event?.has_clip) ||
|
|
||||||
(mediaType === 'snapshots' && !!item.event?.has_snapshot)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a dataview for segments.
|
|
||||||
* @returns A dataview.
|
|
||||||
*/
|
|
||||||
public createSegmentDataView(): DataView<RecordingSegmentsItem> {
|
|
||||||
return new DataView(this._recordingSegments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the underlying recording segments dataset.
|
|
||||||
*/
|
|
||||||
get recordingSegments(): DataSet<RecordingSegmentsItem> {
|
|
||||||
return this._recordingSegments;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite an item as-is. May be useful in cases where clustering may need to
|
|
||||||
* be recalculated.
|
|
||||||
* @param id The id to rewrite.
|
|
||||||
*/
|
|
||||||
public rewriteItem(id: IdType): void {
|
|
||||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
|
||||||
// update the dataset to ensure the newly selected item cannot be included
|
|
||||||
// in a cluster.
|
|
||||||
const item = this._dataset.get(id);
|
|
||||||
if (item) {
|
|
||||||
this._dataset.updateOnly(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add events for the given camera.
|
|
||||||
* @param cameraID The camera ID.
|
|
||||||
* @param events The array of events.
|
|
||||||
*/
|
|
||||||
protected _addEvents(cameraID: string, events: FrigateEvents): void {
|
|
||||||
this._dataset.update(
|
|
||||||
events.map((event) => ({
|
|
||||||
id: event.id,
|
|
||||||
group: cameraID,
|
|
||||||
content: '',
|
|
||||||
event: event,
|
|
||||||
start: event.start_time * 1000,
|
|
||||||
type: event.end_time ? 'range' : 'point',
|
|
||||||
...(event.end_time && { end: event.end_time * 1000 }),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if the timeline has coverage for a given range of dates.
|
|
||||||
* @param start The start of the date range.
|
|
||||||
* @param end An optional end of the date range.
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
|
|
||||||
// Never fetched: no coverage.
|
|
||||||
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the most recent fetch is older than maxAgeSeconds: no coverage.
|
|
||||||
if (
|
|
||||||
this._maxAgeSeconds &&
|
|
||||||
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the most requested data is earlier than the earliest stored: no
|
|
||||||
// coverage.
|
|
||||||
if (start < this._dateStart) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's no end time specified: there IS coverage.
|
|
||||||
if (!end) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// If the requested end time is older than the oldest requested: there IS
|
|
||||||
// coverage.
|
|
||||||
if (end.getTime() < this._dateEnd.getTime()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// If there's no maxAgeSeconds specified: no coverage.
|
|
||||||
if (!this._maxAgeSeconds) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
|
|
||||||
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// End time is within `_maxAgeSeconds` of the latest data: there IS
|
|
||||||
// coverage.
|
|
||||||
return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch events if no coverage in given range.
|
|
||||||
* @param element The element to send error events from.
|
|
||||||
* @param hass The HomeAssistant object.
|
|
||||||
* @param start Fetch events that start later than this date.
|
|
||||||
* @param end Fetch events that start earlier than this date.
|
|
||||||
* @returns `true` if events were fetched, `false` otherwise.
|
|
||||||
*/
|
|
||||||
public async fetchIfNecessary(
|
|
||||||
element: HTMLElement,
|
|
||||||
hass: ExtendedHomeAssistant,
|
|
||||||
start: Date,
|
|
||||||
end: Date,
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Cannot fetch the future, always clip the end date to now so as to avoid
|
|
||||||
// checking for coverage that could not possibly exist yet.
|
|
||||||
const now = new Date();
|
|
||||||
end = end > now ? now : end;
|
|
||||||
|
|
||||||
if (this.hasCoverage(now, start, end)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldStart = this._dateStart;
|
|
||||||
const oldEnd = this._dateEnd;
|
|
||||||
let segmentStart: Date | null = null;
|
|
||||||
let segmentEnd: Date | null = null;
|
|
||||||
if (!this._dateStart || start < this._dateStart) {
|
|
||||||
this._dateStart = start;
|
|
||||||
segmentStart = start;
|
|
||||||
} else {
|
|
||||||
segmentStart = oldEnd ?? end;
|
|
||||||
}
|
|
||||||
if (!this._dateEnd || end > this._dateEnd) {
|
|
||||||
this._dateEnd = end;
|
|
||||||
segmentEnd = end;
|
|
||||||
} else {
|
|
||||||
segmentEnd = oldStart ?? start;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._dateFetch = new Date();
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
// Events are always fetched for the maximum extent of the managed
|
|
||||||
// range. This is because events may change at any point in time
|
|
||||||
// (e.g. a long-running event that ends).
|
|
||||||
this._fetchEvents(element, hass, this._dateStart, this._dateEnd),
|
|
||||||
this._fetchRecordingSummary(hass),
|
|
||||||
...(segmentEnd > segmentStart
|
|
||||||
? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)]
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this._throttledSegmentGarbageCollector();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Garbage collect recording segments that no longer feature in the summary.
|
|
||||||
*/
|
|
||||||
protected _garbageCollectSegments(): void {
|
|
||||||
if (!this._recordingSegments || !this._recordingSummary) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
|
|
||||||
// items) and each item must be examined, so care required here to stick to
|
|
||||||
// nothing worse than O(n) performance.
|
|
||||||
const getHourID = (cameraID: string, day: number, hour: number): string => {
|
|
||||||
return `${cameraID}/${day}/${hour}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const goodHours: Set<string> = new Set();
|
|
||||||
for (const cameraID of this._recordingSummary.keys()) {
|
|
||||||
for (const summaryDay of this._recordingSummary?.get(cameraID) ?? []) {
|
|
||||||
for (const summaryHour of summaryDay.hours) {
|
|
||||||
goodHours.add(getHourID(cameraID, summaryDay.day.getDate(), summaryHour.hour));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteIDs: string[] = [];
|
|
||||||
this._recordingSegments.forEach((item, id) => {
|
|
||||||
const startDate = fromUnixTime(item.start / 1000);
|
|
||||||
const hourID = getHourID(item.cameraID, startDate.getDate(), startDate.getHours());
|
|
||||||
|
|
||||||
// ~O(1) lookup time for a JS set.
|
|
||||||
if (!goodHours.has(hourID)) {
|
|
||||||
deleteIDs.push(String(id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this._recordingSegments.remove(deleteIDs);
|
|
||||||
this._compressRecordingSegmentsOntoTimeline();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch recording segments for cameras.
|
|
||||||
* @param hass The HomeAssistant object.
|
|
||||||
* @param start Fetch segments that start later than this date.
|
|
||||||
* @param end Fetch segments that start earlier than this date.
|
|
||||||
*/
|
|
||||||
protected async _fetchRecordingSegments(
|
|
||||||
hass: ExtendedHomeAssistant,
|
|
||||||
start: Date,
|
|
||||||
end: Date,
|
|
||||||
): Promise<void> {
|
|
||||||
const results: Map<string, RecordingSegments> = new Map();
|
|
||||||
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
|
|
||||||
if (!config || !config.frigate.camera_name || !hass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cameraResults = await getRecordingSegments(
|
|
||||||
hass,
|
|
||||||
config.frigate.client_id,
|
|
||||||
config.frigate.camera_name,
|
|
||||||
end,
|
|
||||||
start,
|
|
||||||
);
|
|
||||||
results.set(camera, cameraResults);
|
|
||||||
} catch (e) {
|
|
||||||
errorToConsole(e as Error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
Array.from(this._cameras.keys()).map((camera) =>
|
|
||||||
fetch(camera, this._cameras.get(camera)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const items: RecordingSegmentsItem[] = [];
|
|
||||||
results.forEach((segments, cameraID) => {
|
|
||||||
segments.forEach((segment) => {
|
|
||||||
items.push({
|
|
||||||
id: `${cameraID}/${segment.id}`,
|
|
||||||
cameraID: cameraID,
|
|
||||||
start: segment.start_time * 1000,
|
|
||||||
end: segment.end_time * 1000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this._recordingSegments.update(items);
|
|
||||||
this._compressRecordingSegmentsOntoTimeline();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compress recording segments into recordings shown on the timeline
|
|
||||||
* background.
|
|
||||||
*/
|
|
||||||
protected _compressRecordingSegmentsOntoTimeline(): void {
|
|
||||||
if (!this._recordingSegments.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all the existing background.
|
|
||||||
this._dataset.remove(
|
|
||||||
this._dataset.get({
|
|
||||||
filter: (item) => item.type === 'background',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const convertToRecording = (
|
|
||||||
segment: RecordingSegmentsItem,
|
|
||||||
): FrigateCardTimelineItem => {
|
|
||||||
return {
|
|
||||||
id: `recording-${segment.cameraID}-${segment.id}`,
|
|
||||||
group: segment.cameraID,
|
|
||||||
start: segment.start,
|
|
||||||
end: segment.end,
|
|
||||||
content: ' ',
|
|
||||||
type: 'background',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Iterate through the segments least to most recent, effectively joining
|
|
||||||
// segments together that are within a certain tolerance to create large
|
|
||||||
// blocks that are visualized on the timeline as recordings.
|
|
||||||
const recordings: FrigateCardTimelineItem[] = [];
|
|
||||||
|
|
||||||
this._cameras.forEach((_, cameraID) => {
|
|
||||||
const segments = this._recordingSegments.get({
|
|
||||||
filter: (item) => item.cameraID === cameraID,
|
|
||||||
order: sortOldestToYoungest,
|
|
||||||
});
|
|
||||||
let current: RecordingSegmentsItem | null = null;
|
|
||||||
for (let i = 0; i < segments.length; ++i) {
|
|
||||||
const item = segments[i];
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
current = { ...item };
|
|
||||||
} else if (current.end + RECORDING_SEGMENT_TOLERANCE * 1000 >= item.start) {
|
|
||||||
current.end = item.end;
|
|
||||||
} else {
|
|
||||||
recordings.push(convertToRecording(current));
|
|
||||||
current = null;
|
|
||||||
}
|
|
||||||
if (i === segments.length - 1 && current) {
|
|
||||||
recordings.push(convertToRecording(current));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this._dataset.update(recordings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch recording summary.
|
|
||||||
* @param hass The HomeAssistant object.
|
|
||||||
*/
|
|
||||||
protected async _fetchRecordingSummary(hass: ExtendedHomeAssistant): Promise<void> {
|
|
||||||
const storeRecordingSummary = async (
|
|
||||||
cameraID: string,
|
|
||||||
cameraConfig: CameraConfig,
|
|
||||||
): Promise<void> => {
|
|
||||||
if (!cameraConfig.frigate.camera_name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this._recordingSummary.set(
|
|
||||||
cameraID,
|
|
||||||
await getRecordingsSummary(
|
|
||||||
hass,
|
|
||||||
cameraConfig.frigate.client_id,
|
|
||||||
cameraConfig.frigate.camera_name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Recording failure should not disrupt the rest of the timeline
|
|
||||||
// experience.
|
|
||||||
errorToConsole(e as Error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
Array.from(this._cameras.keys()).map(async (cameraID) => {
|
|
||||||
const cameraConfig = this._cameras.get(cameraID);
|
|
||||||
if (cameraConfig) {
|
|
||||||
await storeRecordingSummary(cameraID, cameraConfig);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch events for the timeline.
|
|
||||||
* @param element The element to send error events from.
|
|
||||||
* @param hass The HomeAssistant object.
|
|
||||||
* @param start Fetch events that start later than this date.
|
|
||||||
* @param end Fetch events that start earlier than this date.
|
|
||||||
*/
|
|
||||||
protected async _fetchEvents(
|
|
||||||
element: HTMLElement,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
start: Date,
|
|
||||||
end: Date,
|
|
||||||
): Promise<void> {
|
|
||||||
const params: Map<string, FrigateGetEventsParameters> = new Map();
|
|
||||||
|
|
||||||
this._cameras.forEach((cameraConfig, cameraID) => {
|
|
||||||
if (
|
|
||||||
cameraConfig.frigate.camera_name &&
|
|
||||||
cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE
|
|
||||||
) {
|
|
||||||
params.set(cameraID, {
|
|
||||||
instance_id: cameraConfig.frigate.client_id,
|
|
||||||
camera: cameraConfig.frigate.camera_name,
|
|
||||||
...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }),
|
|
||||||
...(cameraConfig.frigate.zone && { label: cameraConfig.frigate.zone }),
|
|
||||||
before: Math.floor(end.getTime() / 1000),
|
|
||||||
after: Math.floor(start.getTime() / 1000),
|
|
||||||
limit: DATA_MANAGER_MAX_FETCH_COUNT,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let results: Map<string, FrigateEvents>;
|
|
||||||
try {
|
|
||||||
results = await getEventsMultiple(hass, params);
|
|
||||||
} catch (e) {
|
|
||||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
|
||||||
}
|
|
||||||
results.forEach((params, cameraID) => this._addEvents(cameraID, params));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
|
import orderBy from 'lodash-es/orderBy';
|
||||||
|
import sortedUniqBy from 'lodash-es/sortedUniqBy';
|
||||||
|
import { RecordingSegment, RecordingSegments } from '../frigate';
|
||||||
|
import { DateRange, MemoryRangeSet } from './data-manager-range';
|
||||||
|
import { DataQuery, QueryResults } from './data-types';
|
||||||
|
|
||||||
|
interface RequestCacheItem<Request, Response> {
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
expires?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataManagerCache<Request, Response> {
|
||||||
|
get(request: Request): Response | null;
|
||||||
|
has(request: Request): boolean;
|
||||||
|
set(request: Request, response: Response, expiry?: Date): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryRequestCache<Request, Response>
|
||||||
|
implements DataManagerCache<Request, Response>
|
||||||
|
{
|
||||||
|
protected _data: RequestCacheItem<Request, Response>[] = [];
|
||||||
|
|
||||||
|
public get(request: Request): Response | null {
|
||||||
|
const now = this._now();
|
||||||
|
for (const item of this._data) {
|
||||||
|
if (
|
||||||
|
(!item.expires || now <= item.expires) &&
|
||||||
|
this._contains(request, item.request)
|
||||||
|
) {
|
||||||
|
return item.response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public has(request: Request): boolean {
|
||||||
|
return !!this.get(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(request: Request, response: Response, expiry?: Date): void {
|
||||||
|
this._data.push({
|
||||||
|
request: request,
|
||||||
|
response: response,
|
||||||
|
expires: expiry,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old requests on set.
|
||||||
|
this._expireOldRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _now(): Date {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _contains(a: Request, b: Request): boolean {
|
||||||
|
return isEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _expireOldRequests(): void {
|
||||||
|
const now = this._now();
|
||||||
|
this._data = this._data.filter((item) => !item.expires || now < item.expires);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
|
||||||
|
|
||||||
|
export class MemoryRangedCache<Data> {
|
||||||
|
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
|
||||||
|
protected _data: Data[] = [];
|
||||||
|
protected _timeFunc: (data: Data) => number;
|
||||||
|
protected _idFunc: (data: Data) => string;
|
||||||
|
|
||||||
|
constructor(timeFunc: (data: Data) => number, idFunc: (data: Data) => string) {
|
||||||
|
this._timeFunc = timeFunc;
|
||||||
|
this._idFunc = idFunc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public add(range: DateRange, data: Data[]) {
|
||||||
|
this._ranges.add(range);
|
||||||
|
this._data = sortedUniqBy(
|
||||||
|
orderBy(this._data.concat(data), this._timeFunc, 'asc'),
|
||||||
|
this._idFunc,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasCoverage(range: DateRange): boolean {
|
||||||
|
return this._ranges.hasCoverage(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(range: DateRange): Data[] | null {
|
||||||
|
if (!this.hasCoverage(range)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: Data[] = [];
|
||||||
|
for (const data of this._data) {
|
||||||
|
const start = this._timeFunc(data);
|
||||||
|
if (start > range.start.getTime()) {
|
||||||
|
if (start > range.end.getTime()) {
|
||||||
|
// Data is kept in order.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
output.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordingSegmentsCache {
|
||||||
|
protected _segments: Map<string, MemoryRangedCache<RecordingSegment>> = new Map();
|
||||||
|
|
||||||
|
public add(cameraID: string, range: DateRange, segments: RecordingSegments) {
|
||||||
|
let cameraSegmentCache: MemoryRangedCache<RecordingSegment> | undefined =
|
||||||
|
this._segments.get(cameraID);
|
||||||
|
if (!cameraSegmentCache) {
|
||||||
|
cameraSegmentCache = new MemoryRangedCache(
|
||||||
|
(segment: RecordingSegment) => segment.start_time * 1000,
|
||||||
|
(segment: RecordingSegment) => segment.id,
|
||||||
|
);
|
||||||
|
this._segments.set(cameraID, cameraSegmentCache);
|
||||||
|
}
|
||||||
|
cameraSegmentCache.add(range, segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasCoverage(cameraID: string, range: DateRange): boolean {
|
||||||
|
return !!this._segments.get(cameraID)?.hasCoverage(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(cameraID: string, range: DateRange): RecordingSegments | null {
|
||||||
|
return this._segments.get(cameraID)?.get(range) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { CameraConfig } from '../../types';
|
||||||
|
import { RecordingSegmentsCache } from './data-manager-cache';
|
||||||
|
import { DataManagerEngine } from './data-manager-engine';
|
||||||
|
import { FrigateDataManagerEngine } from './data-manager-engine-frigate';
|
||||||
|
import { DataQuery } from './data-types';
|
||||||
|
|
||||||
|
export class DataManagerEngineFactory {
|
||||||
|
protected _engines: Map<string, DataManagerEngine> = new Map();
|
||||||
|
|
||||||
|
protected _getOrCreateEngine(engineKey: string): DataManagerEngine | null {
|
||||||
|
const cachedEngine = this._engines.get(engineKey);
|
||||||
|
if (cachedEngine) {
|
||||||
|
return cachedEngine;
|
||||||
|
}
|
||||||
|
let newEngine: DataManagerEngine | null = null;
|
||||||
|
switch (engineKey) {
|
||||||
|
case 'frigate':
|
||||||
|
newEngine = new FrigateDataManagerEngine(new RecordingSegmentsCache());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (newEngine) {
|
||||||
|
this._engines.set(engineKey, newEngine);
|
||||||
|
}
|
||||||
|
return newEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEngineForQuery(
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: DataQuery,
|
||||||
|
): DataManagerEngine | null {
|
||||||
|
const cameraConfig = cameras.get(query.cameraID);
|
||||||
|
return cameraConfig ? this.getEngineForCamera(cameraConfig) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEngineForCamera(cameraConfig: CameraConfig): DataManagerEngine | null {
|
||||||
|
let engineKey: string | null = null;
|
||||||
|
if (cameraConfig.frigate.camera_name) {
|
||||||
|
engineKey = 'frigate';
|
||||||
|
}
|
||||||
|
return engineKey ? this._getOrCreateEngine(engineKey) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import add from 'date-fns/add';
|
||||||
|
import endOfHour from 'date-fns/endOfHour';
|
||||||
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
|
import startOfHour from 'date-fns/startOfHour';
|
||||||
|
import { CAMERA_BIRDSEYE } from '../../const';
|
||||||
|
import { CameraConfig, FrigateRecording } from '../../types';
|
||||||
|
import { MediaQueries, MediaQueriesResults } from '../../view';
|
||||||
|
import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media';
|
||||||
|
import { errorToConsole } from '../basic';
|
||||||
|
import {
|
||||||
|
getEvents,
|
||||||
|
getRecordingSegments,
|
||||||
|
getRecordingsSummary,
|
||||||
|
NativeFrigateEventQuery,
|
||||||
|
NativeFrigateRecordingSegmentsQuery,
|
||||||
|
RecordingSegments,
|
||||||
|
RecordingSummary,
|
||||||
|
retainEvent,
|
||||||
|
} from '../frigate';
|
||||||
|
import { RecordingSegmentsCache } from './data-manager-cache';
|
||||||
|
import {
|
||||||
|
DataManagerEngine,
|
||||||
|
DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||||
|
} from './data-manager-engine';
|
||||||
|
import { DataManagerError } from './data-manager-error';
|
||||||
|
import { DateRange } from './data-manager-range';
|
||||||
|
import {
|
||||||
|
Engine,
|
||||||
|
EventQuery,
|
||||||
|
FrigateEventQueryResults,
|
||||||
|
FrigateRecordingQueryResults,
|
||||||
|
FrigateRecordingSegmentsQueryResults,
|
||||||
|
PartialEventQuery,
|
||||||
|
PartialRecordingQuery,
|
||||||
|
PartialRecordingSegmentsQuery,
|
||||||
|
QueryResults,
|
||||||
|
QueryResultsType,
|
||||||
|
QueryReturnType,
|
||||||
|
QueryType,
|
||||||
|
RecordingQuery,
|
||||||
|
RecordingSegmentsQuery,
|
||||||
|
} from './data-types';
|
||||||
|
|
||||||
|
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
|
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
|
|
||||||
|
class FrigateQueryResultsClassifier {
|
||||||
|
public static isFrigateEventQueryResults(
|
||||||
|
results: QueryResults,
|
||||||
|
): results is FrigateEventQueryResults {
|
||||||
|
return results.engine === Engine.Frigate && results.type === QueryResultsType.Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isFrigateRecordingQueryResults(
|
||||||
|
results: QueryResults,
|
||||||
|
): results is FrigateRecordingQueryResults {
|
||||||
|
return (
|
||||||
|
results.engine === Engine.Frigate && results.type === QueryResultsType.Recording
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isFrigateRecordingSegmentsResults(
|
||||||
|
results: QueryResults,
|
||||||
|
): results is FrigateRecordingSegmentsQueryResults {
|
||||||
|
return (
|
||||||
|
results.engine === Engine.Frigate &&
|
||||||
|
results.type === QueryResultsType.RecordingSegments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FrigateDataManagerEngine implements DataManagerEngine {
|
||||||
|
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||||
|
|
||||||
|
constructor(recordingSegmentsCache: RecordingSegmentsCache) {
|
||||||
|
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMediaDownloadPath(
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
): string | null {
|
||||||
|
let path: string | null = null;
|
||||||
|
if (ViewMediaClassifier.isFrigateEvent(media)) {
|
||||||
|
path =
|
||||||
|
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||||
|
`/notifications/${media.getID()}/` +
|
||||||
|
`${media.isClip() ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||||
|
`?download=true`;
|
||||||
|
} else if (ViewMediaClassifier.isFrigateRecording(media)) {
|
||||||
|
path =
|
||||||
|
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||||
|
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||||
|
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||||
|
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||||
|
`?download=true`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultEventQuery(
|
||||||
|
cameraID: string,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
query: PartialEventQuery,
|
||||||
|
): EventQuery | null {
|
||||||
|
return {
|
||||||
|
type: QueryType.Event,
|
||||||
|
cameraID: cameraID,
|
||||||
|
...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }),
|
||||||
|
...(cameraConfig.frigate.zone && { zone: cameraConfig.frigate.zone }),
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultRecordingQuery(
|
||||||
|
cameraID: string,
|
||||||
|
_cameraConfig: CameraConfig,
|
||||||
|
query: PartialRecordingQuery,
|
||||||
|
): RecordingQuery | null {
|
||||||
|
return {
|
||||||
|
type: QueryType.Recording,
|
||||||
|
cameraID: cameraID,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultRecordingSegmentsQuery(
|
||||||
|
cameraID: string,
|
||||||
|
_cameraConfig: CameraConfig,
|
||||||
|
query: PartialRecordingSegmentsQuery,
|
||||||
|
): RecordingSegmentsQuery | null {
|
||||||
|
if (!query.start || !query.end) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: QueryType.RecordingSegments,
|
||||||
|
cameraID: cameraID,
|
||||||
|
start: query.start,
|
||||||
|
end: query.end,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async favoriteMedia(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
favorite: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const clientID = cameraConfig.frigate.client_id;
|
||||||
|
if (!ViewMediaClassifier.isFrigateEvent(media)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await retainEvent(hass, clientID, media.getID(cameraConfig), favorite);
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
throw new DataManagerError((e as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
media.setFavorite(favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: EventQuery,
|
||||||
|
): Promise<QueryReturnType<EventQuery> | null> {
|
||||||
|
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeQuery: NativeFrigateEventQuery = {
|
||||||
|
instance_id: cameraConfig.frigate.client_id,
|
||||||
|
camera: cameraConfig.frigate.camera_name,
|
||||||
|
...(query.what && { label: query.what }),
|
||||||
|
...(query.where && { zone: query.where }),
|
||||||
|
...(query?.end && { before: Math.floor(query.end.getTime() / 1000) }),
|
||||||
|
...(query?.start && { after: Math.floor(query.start.getTime() / 1000) }),
|
||||||
|
...(query?.limit && { limit: query.limit }),
|
||||||
|
...(query?.hasClip && { has_clip: query.hasClip }),
|
||||||
|
...(query?.hasSnapshot && { has_snapshot: query.hasSnapshot }),
|
||||||
|
limit: query?.limit ?? DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: FrigateEventQueryResults = {
|
||||||
|
type: QueryResultsType.Event,
|
||||||
|
engine: Engine.Frigate,
|
||||||
|
events: await getEvents(hass, nativeQuery),
|
||||||
|
expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
throw new DataManagerError((e as Error).message, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecordings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: RecordingQuery,
|
||||||
|
): Promise<QueryReturnType<RecordingQuery> | null> {
|
||||||
|
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recordingSummary: RecordingSummary;
|
||||||
|
try {
|
||||||
|
recordingSummary = await getRecordingsSummary(
|
||||||
|
hass,
|
||||||
|
cameraConfig.frigate.client_id,
|
||||||
|
cameraConfig.frigate.camera_name,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
throw new DataManagerError((e as Error).message, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordings: FrigateRecording[] = [];
|
||||||
|
for (const dayData of recordingSummary ?? []) {
|
||||||
|
for (const hourData of dayData.hours) {
|
||||||
|
const hour = add(dayData.day, { hours: hourData.hour });
|
||||||
|
const startHour = startOfHour(hour);
|
||||||
|
const endHour = endOfHour(hour);
|
||||||
|
if (
|
||||||
|
(!query.start || startHour >= query.start) &&
|
||||||
|
(!query.end || endHour <= query.end)
|
||||||
|
) {
|
||||||
|
recordings.push({
|
||||||
|
camera: cameraConfig.frigate.camera_name,
|
||||||
|
start_time: getUnixTime(startHour),
|
||||||
|
end_time: getUnixTime(endHour),
|
||||||
|
events: hourData.events,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FrigateRecordingQueryResults>{
|
||||||
|
type: QueryResultsType.Recording,
|
||||||
|
engine: Engine.Frigate,
|
||||||
|
recordings: recordings,
|
||||||
|
expiry: add(new Date(), {
|
||||||
|
seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecordingSegments(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: RecordingSegmentsQuery,
|
||||||
|
): Promise<QueryReturnType<RecordingSegmentsQuery> | null> {
|
||||||
|
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
|
||||||
|
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range: DateRange = { start: query.start, end: query.end };
|
||||||
|
|
||||||
|
// A note on Frigate Recording Segments:
|
||||||
|
// - Unlike other query types, there is an internal cache at the engine
|
||||||
|
// level for segments to allow caching "within an existing query" (e.g. if
|
||||||
|
// we already cached hour 1-8, we will avoid a fetch if we request hours
|
||||||
|
// 2-3 even though the query is different -- the segments won't be). This
|
||||||
|
// is since the volume of data in segment transfers can be high, and the
|
||||||
|
// segments can be used in high frequency situations (e.g. video seeking).
|
||||||
|
const cachedSegments = this._recordingSegmentsCache.get(query.cameraID, range);
|
||||||
|
if (cachedSegments) {
|
||||||
|
return {
|
||||||
|
type: QueryResultsType.RecordingSegments,
|
||||||
|
engine: Engine.Frigate,
|
||||||
|
segments: cachedSegments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: NativeFrigateRecordingSegmentsQuery = {
|
||||||
|
instance_id: cameraConfig.frigate.client_id,
|
||||||
|
camera: cameraConfig.frigate.camera_name,
|
||||||
|
after: Math.floor(query.start.getTime() / 1000),
|
||||||
|
before: Math.floor(query.end.getTime() / 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
let segments: RecordingSegments;
|
||||||
|
try {
|
||||||
|
segments = await getRecordingSegments(hass, request);
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
throw new DataManagerError((e as Error).message, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._recordingSegmentsCache.add(query.cameraID, range, segments);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: QueryResultsType.RecordingSegments,
|
||||||
|
engine: Engine.Frigate,
|
||||||
|
segments: segments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateMediaFromEvents(
|
||||||
|
query: EventQuery,
|
||||||
|
results: QueryReturnType<EventQuery>,
|
||||||
|
): ViewMedia[] | null {
|
||||||
|
if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: ViewMedia[] = [];
|
||||||
|
for (const event of results.events) {
|
||||||
|
let mediaType: 'clip' | 'snapshot' | null = null;
|
||||||
|
if (
|
||||||
|
!query.hasClip &&
|
||||||
|
!query.hasSnapshot &&
|
||||||
|
(event.has_clip || event.has_snapshot)
|
||||||
|
) {
|
||||||
|
mediaType = event.has_clip ? 'clip' : 'snapshot';
|
||||||
|
} else if (query.hasSnapshot && event.has_snapshot) {
|
||||||
|
mediaType = 'snapshot';
|
||||||
|
} else if (query.hasClip && event.has_clip) {
|
||||||
|
mediaType = 'clip';
|
||||||
|
}
|
||||||
|
if (!mediaType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const media = ViewMediaFactory.createViewMediaFromFrigateEvent(
|
||||||
|
mediaType,
|
||||||
|
query.cameraID,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
if (media) {
|
||||||
|
output.push(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateMediaFromRecordings(
|
||||||
|
query: RecordingQuery,
|
||||||
|
results: QueryReturnType<RecordingQuery>,
|
||||||
|
): ViewMedia[] | null {
|
||||||
|
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: ViewMedia[] = [];
|
||||||
|
for (const recording of results.recordings) {
|
||||||
|
const media = ViewMediaFactory.createViewMediaFromFrigateRecording(
|
||||||
|
query.cameraID,
|
||||||
|
recording,
|
||||||
|
);
|
||||||
|
if (media) {
|
||||||
|
output.push(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public areMediaQueriesResultsFresh(
|
||||||
|
queries: MediaQueries,
|
||||||
|
results: MediaQueriesResults,
|
||||||
|
): boolean {
|
||||||
|
let freshThreshold: number | null = null;
|
||||||
|
if (queries.areEventQueries()) {
|
||||||
|
freshThreshold = EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||||
|
} else if (queries.areRecordingQueries()) {
|
||||||
|
freshThreshold = RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const resultsTimestamp = results.getResultsTimestamp();
|
||||||
|
return (
|
||||||
|
!freshThreshold ||
|
||||||
|
!resultsTimestamp ||
|
||||||
|
add(resultsTimestamp, { seconds: freshThreshold }) >= now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getQueryableCameraConfig(
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
cameraID: string,
|
||||||
|
): CameraConfig | null {
|
||||||
|
const cameraConfig = cameras.get(cameraID);
|
||||||
|
if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cameraConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { CameraConfig } from '../../types';
|
||||||
|
import { MediaQueries, MediaQueriesResults } from '../../view';
|
||||||
|
import { ViewMedia } from '../../view-media';
|
||||||
|
import {
|
||||||
|
EventQuery,
|
||||||
|
PartialEventQuery,
|
||||||
|
PartialRecordingQuery,
|
||||||
|
PartialRecordingSegmentsQuery,
|
||||||
|
QueryReturnType,
|
||||||
|
RecordingQuery,
|
||||||
|
RecordingSegmentsQuery,
|
||||||
|
} from './data-types';
|
||||||
|
|
||||||
|
export const DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||||
|
|
||||||
|
export interface DataManagerEngine {
|
||||||
|
generateDefaultEventQuery(
|
||||||
|
cameraID: string,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
query: PartialEventQuery,
|
||||||
|
): EventQuery | null;
|
||||||
|
|
||||||
|
generateDefaultRecordingQuery(
|
||||||
|
cameraID: string,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
query: PartialRecordingQuery,
|
||||||
|
): RecordingQuery | null;
|
||||||
|
|
||||||
|
generateDefaultRecordingSegmentsQuery(
|
||||||
|
cameraID: string,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
query: PartialRecordingSegmentsQuery,
|
||||||
|
): RecordingSegmentsQuery | null;
|
||||||
|
|
||||||
|
getEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: EventQuery,
|
||||||
|
): Promise<QueryReturnType<EventQuery> | null>;
|
||||||
|
|
||||||
|
getRecordings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: RecordingQuery,
|
||||||
|
): Promise<QueryReturnType<RecordingQuery> | null>;
|
||||||
|
|
||||||
|
getRecordingSegments(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: RecordingSegmentsQuery,
|
||||||
|
): Promise<QueryReturnType<RecordingSegmentsQuery> | null>;
|
||||||
|
|
||||||
|
generateMediaFromEvents(
|
||||||
|
query: EventQuery,
|
||||||
|
results: QueryReturnType<EventQuery>,
|
||||||
|
): ViewMedia[] | null;
|
||||||
|
|
||||||
|
generateMediaFromRecordings(
|
||||||
|
query: RecordingQuery,
|
||||||
|
results: QueryReturnType<RecordingQuery>,
|
||||||
|
): ViewMedia[] | null;
|
||||||
|
|
||||||
|
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
|
||||||
|
|
||||||
|
favoriteMedia(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
favorite: boolean,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
areMediaQueriesResultsFresh(
|
||||||
|
queries: MediaQueries,
|
||||||
|
results: MediaQueriesResults,
|
||||||
|
): boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { FrigateCardError } from '../../types';
|
||||||
|
|
||||||
|
export class DataManagerError extends FrigateCardError {}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import cloneDeep from 'lodash-es/cloneDeep';
|
||||||
|
import orderBy from 'lodash-es/orderBy';
|
||||||
|
|
||||||
|
interface Range<T extends Date | number> {
|
||||||
|
start: T;
|
||||||
|
end: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateRange = Range<Date>;
|
||||||
|
|
||||||
|
export class MemoryRangeSet {
|
||||||
|
protected _ranges: DateRange[];
|
||||||
|
|
||||||
|
constructor(ranges?: DateRange[]) {
|
||||||
|
this._ranges = ranges ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone(): MemoryRangeSet {
|
||||||
|
return new MemoryRangeSet(cloneDeep(this._ranges));
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasCoverage(range: DateRange): boolean {
|
||||||
|
return this._ranges.some((cachedRange) =>
|
||||||
|
this._isEntirelyContained(cachedRange, range),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public add(range: DateRange): void {
|
||||||
|
this._ranges.push(range);
|
||||||
|
this._ranges = compressRanges(this._ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _isEntirelyContained(bigger: DateRange, smaller: DateRange): boolean {
|
||||||
|
return smaller.start >= bigger.start && smaller.end <= bigger.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
|
||||||
|
return (
|
||||||
|
// a starts within the range of b.
|
||||||
|
(a.start >= b.start && a.start <= b.end) ||
|
||||||
|
// a events within the range of b.
|
||||||
|
(a.end >= b.start && a.end <= b.end) ||
|
||||||
|
// a encompasses the entire range of b.
|
||||||
|
(a.start <= b.start && a.end >= b.end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const compressRanges = <T extends Date | number>(
|
||||||
|
ranges: Range<T>[],
|
||||||
|
toleranceSeconds = 0,
|
||||||
|
): Range<T>[] => {
|
||||||
|
const compressedRanges: Range<T>[] = [];
|
||||||
|
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||||
|
|
||||||
|
let current: Range<T> | null = null;
|
||||||
|
for (let i = 0; i < ranges.length; ++i) {
|
||||||
|
const item = ranges[i];
|
||||||
|
const itemStartSeconds =
|
||||||
|
item.start instanceof Date ? item.start.getTime() : item.start;
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
current = { ...item };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEndSeconds =
|
||||||
|
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
||||||
|
|
||||||
|
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
|
||||||
|
if (item.end > current.end) {
|
||||||
|
current.end = item.end;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
compressedRanges.push(current);
|
||||||
|
current = { ...item };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
compressedRanges.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressedRanges;
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import startOfHour from 'date-fns/startOfHour';
|
||||||
|
import endOfHour from 'date-fns/endOfHour';
|
||||||
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import endOfMinute from 'date-fns/endOfMinute';
|
||||||
|
import endOfWeek from 'date-fns/endOfWeek';
|
||||||
|
import startOfWeek from 'date-fns/startOfWeek';
|
||||||
|
import { DateRange } from './data-manager-range';
|
||||||
|
|
||||||
|
export const convertRangeToCacheFriendlyTimes = (
|
||||||
|
range: DateRange,
|
||||||
|
options?: {
|
||||||
|
endCap?: boolean;
|
||||||
|
},
|
||||||
|
): DateRange => {
|
||||||
|
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
|
||||||
|
let cacheableStart: Date;
|
||||||
|
let cacheableEnd: Date;
|
||||||
|
|
||||||
|
if (widthSeconds <= 60 * 60) {
|
||||||
|
cacheableStart = startOfHour(range.start);
|
||||||
|
cacheableEnd = endOfHour(range.end);
|
||||||
|
} else if (widthSeconds <= 60 * 60 * 24) {
|
||||||
|
cacheableStart = startOfDay(range.start);
|
||||||
|
cacheableEnd = endOfDay(range.end);
|
||||||
|
} else {
|
||||||
|
cacheableStart = startOfWeek(range.start);
|
||||||
|
cacheableEnd = endOfWeek(range.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.endCap) {
|
||||||
|
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: cacheableStart,
|
||||||
|
end: cacheableEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const capEndDate = (end: Date): Date => {
|
||||||
|
const now = new Date();
|
||||||
|
return end > now ? now : end;
|
||||||
|
};
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { CameraConfig } from '../../types.js';
|
||||||
|
import { arrayify, setify } from '../basic.js';
|
||||||
|
import {
|
||||||
|
DataQuery,
|
||||||
|
EventQuery,
|
||||||
|
EventQueryResults,
|
||||||
|
PartialDataQuery,
|
||||||
|
PartialEventQuery,
|
||||||
|
PartialQueryConcreteType,
|
||||||
|
PartialRecordingQuery,
|
||||||
|
PartialRecordingSegmentsQuery,
|
||||||
|
QueryResults,
|
||||||
|
QueryResultsType,
|
||||||
|
QueryReturnType,
|
||||||
|
QueryType,
|
||||||
|
RecordingQuery,
|
||||||
|
RecordingQueryResults,
|
||||||
|
RecordingSegmentsQuery,
|
||||||
|
RecordingSegmentsQueryResults,
|
||||||
|
} from './data-types.js';
|
||||||
|
import orderBy from 'lodash-es/orderBy';
|
||||||
|
import { DataManagerEngineFactory } from './data-manager-engine-factory.js';
|
||||||
|
import { ViewMedia } from '../../view-media.js';
|
||||||
|
import { MediaQueries, MediaQueriesResults } from '../../view.js';
|
||||||
|
import { MemoryRequestCache } from './data-manager-cache.js';
|
||||||
|
|
||||||
|
export class QueryClassifier {
|
||||||
|
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||||
|
return query.type === QueryType.Event;
|
||||||
|
}
|
||||||
|
public static isRecordingQuery(
|
||||||
|
query: DataQuery | PartialDataQuery,
|
||||||
|
): query is RecordingQuery {
|
||||||
|
return query.type === QueryType.Recording;
|
||||||
|
}
|
||||||
|
public static isRecordingSegmentsQuery(
|
||||||
|
query: DataQuery | PartialDataQuery,
|
||||||
|
): query is RecordingSegmentsQuery {
|
||||||
|
return query.type === QueryType.RecordingSegments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryResultClassifier {
|
||||||
|
public static isEventQueryResult(
|
||||||
|
queryResults: QueryResults,
|
||||||
|
): queryResults is EventQueryResults {
|
||||||
|
return queryResults.type === QueryResultsType.Event;
|
||||||
|
}
|
||||||
|
public static isRecordingQuery(
|
||||||
|
queryResults: QueryResults,
|
||||||
|
): queryResults is RecordingQueryResults {
|
||||||
|
return queryResults.type === QueryResultsType.Recording;
|
||||||
|
}
|
||||||
|
public static isRecordingSegmentsQuery(
|
||||||
|
queryResults: QueryResults,
|
||||||
|
): queryResults is RecordingSegmentsQueryResults {
|
||||||
|
return queryResults.type === QueryResultsType.RecordingSegments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestCache = MemoryRequestCache<DataQuery, QueryResults>;
|
||||||
|
|
||||||
|
export class DataManager {
|
||||||
|
protected _engineFactory: DataManagerEngineFactory;
|
||||||
|
protected _cameras: Map<string, CameraConfig>;
|
||||||
|
protected _requestCache: RequestCache;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
engineFactory: DataManagerEngineFactory,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
requestCache: RequestCache,
|
||||||
|
) {
|
||||||
|
this._engineFactory = engineFactory;
|
||||||
|
this._cameras = cameras;
|
||||||
|
this._requestCache = requestCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultEventQueries(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery: PartialEventQuery,
|
||||||
|
): EventQuery[] {
|
||||||
|
return this._generateDefaultQueries(cameraIDs, {
|
||||||
|
...partialQuery,
|
||||||
|
type: QueryType.Event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultRecordingQueries(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery: PartialRecordingQuery,
|
||||||
|
): RecordingQuery[] {
|
||||||
|
return this._generateDefaultQueries(cameraIDs, {
|
||||||
|
...partialQuery,
|
||||||
|
type: QueryType.Recording,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultRecordingSegmentsQueries(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery: PartialRecordingSegmentsQuery,
|
||||||
|
): RecordingSegmentsQuery[] {
|
||||||
|
return this._generateDefaultQueries(cameraIDs, {
|
||||||
|
...partialQuery,
|
||||||
|
type: QueryType.RecordingSegments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _generateDefaultQueries<PQT extends Partial<DataQuery>>(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery: PQT,
|
||||||
|
): PartialQueryConcreteType<PQT>[] {
|
||||||
|
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
||||||
|
const _cameraIDs = setify(cameraIDs);
|
||||||
|
|
||||||
|
_cameraIDs.forEach((cameraID) => {
|
||||||
|
const cameraConfig = this._cameras.get(cameraID);
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||||
|
if (!engine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query: DataQuery | null = null;
|
||||||
|
if (QueryClassifier.isEventQuery(partialQuery)) {
|
||||||
|
query = engine.generateDefaultEventQuery(cameraID, cameraConfig, partialQuery);
|
||||||
|
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
||||||
|
query = engine.generateDefaultRecordingQuery(
|
||||||
|
cameraID,
|
||||||
|
cameraConfig,
|
||||||
|
partialQuery,
|
||||||
|
);
|
||||||
|
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||||
|
query = engine.generateDefaultRecordingSegmentsQuery(
|
||||||
|
cameraID,
|
||||||
|
cameraConfig,
|
||||||
|
partialQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return concreteQueries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: EventQuery | EventQuery[],
|
||||||
|
): Promise<Map<EventQuery, EventQueryResults>> {
|
||||||
|
return await this._handleQuery(hass, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecordings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: RecordingQuery | RecordingQuery[],
|
||||||
|
): Promise<Map<RecordingQuery, RecordingQueryResults>> {
|
||||||
|
return await this._handleQuery(hass, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecordingSegments(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
|
||||||
|
): Promise<Map<RecordingSegmentsQuery, RecordingSegmentsQueryResults>> {
|
||||||
|
return await this._handleQuery(hass, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async executeMediaQuery(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mediaQuerys: MediaQueries,
|
||||||
|
): Promise<MediaQueriesResults | null> {
|
||||||
|
const queries: (RecordingQuery | EventQuery)[] | null = mediaQuerys.getQueries();
|
||||||
|
if (!queries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this._handleQuery(hass, queries);
|
||||||
|
|
||||||
|
const mediaArray: ViewMedia[] = [];
|
||||||
|
for (const [query, result] of results.entries()) {
|
||||||
|
const engine = this._engineFactory.getEngineForQuery(this._cameras, query);
|
||||||
|
if (engine) {
|
||||||
|
let media: ViewMedia[] | null = null;
|
||||||
|
if (
|
||||||
|
QueryClassifier.isEventQuery(query) &&
|
||||||
|
QueryResultClassifier.isEventQueryResult(result)
|
||||||
|
) {
|
||||||
|
media = engine.generateMediaFromEvents(query, result);
|
||||||
|
} else if (
|
||||||
|
QueryClassifier.isRecordingQuery(query) &&
|
||||||
|
QueryResultClassifier.isRecordingQuery(result)
|
||||||
|
) {
|
||||||
|
media = engine.generateMediaFromRecordings(query, result);
|
||||||
|
}
|
||||||
|
if (media) {
|
||||||
|
mediaArray.push(...media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaArray.length
|
||||||
|
? new MediaQueriesResults(
|
||||||
|
orderBy(mediaArray, (media) => media.getStartTime(), 'desc'),
|
||||||
|
// Select the first (most-recent) item.
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMediaDownloadPath(media: ViewMedia): string | null {
|
||||||
|
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||||
|
const engine = cameraConfig
|
||||||
|
? this._engineFactory.getEngineForCamera(cameraConfig)
|
||||||
|
: null;
|
||||||
|
if (!cameraConfig || !engine) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return engine.getMediaDownloadPath(cameraConfig, media);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async favoriteMedia(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
favorite: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||||
|
if (engine) {
|
||||||
|
engine.favoriteMedia(hass, cameraConfig, media, favorite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public areMediaQueriesResultsFresh(
|
||||||
|
queries: MediaQueries,
|
||||||
|
results: MediaQueriesResults,
|
||||||
|
): boolean {
|
||||||
|
const cameraIDs: Set<string> = new Set();
|
||||||
|
(queries.getQueries() ?? []).forEach((query) => cameraIDs.add(query.cameraID));
|
||||||
|
for (const cameraID of cameraIDs) {
|
||||||
|
const cameraConfig = this._cameras.get(cameraID);
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||||
|
if (!engine || !engine.areMediaQueriesResultsFresh(queries, results)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _handleQuery<QT extends DataQuery>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: QT | QT[],
|
||||||
|
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||||
|
const _queries = arrayify(query);
|
||||||
|
const results = new Map<QT, QueryReturnType<QT>>();
|
||||||
|
|
||||||
|
const queryStartTime = new Date();
|
||||||
|
let queryCachedCount = 0;
|
||||||
|
|
||||||
|
const processQuery = async (query: QT): Promise<void> => {
|
||||||
|
const cachedResult: QueryReturnType<QT> | null = this._requestCache.get(
|
||||||
|
query,
|
||||||
|
) as QueryReturnType<QT> | null;
|
||||||
|
if (cachedResult) {
|
||||||
|
queryCachedCount++;
|
||||||
|
results.set(query, cachedResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = this._engineFactory.getEngineForQuery(this._cameras, query);
|
||||||
|
if (!engine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: QueryResults | null = null;
|
||||||
|
if (QueryClassifier.isEventQuery(query)) {
|
||||||
|
result = await engine.getEvents(hass, this._cameras, query);
|
||||||
|
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||||
|
result = await engine.getRecordings(hass, this._cameras, query);
|
||||||
|
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||||
|
result = await engine.getRecordingSegments(hass, this._cameras, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
if (result.expiry) {
|
||||||
|
this._requestCache.set(query, result, result.expiry);
|
||||||
|
}
|
||||||
|
results.set(query, result as QueryReturnType<QT>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(_queries.map((query) => processQuery(query)));
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
'Frigate Card DataManager request (Cached:',
|
||||||
|
`${queryCachedCount}/${_queries.length},`,
|
||||||
|
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||||
|
'Queries:',
|
||||||
|
_queries,
|
||||||
|
', Results:',
|
||||||
|
results,
|
||||||
|
')',
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { FrigateEvents, FrigateRecording } from '../../types';
|
||||||
|
import { RecordingSegments } from '../frigate';
|
||||||
|
|
||||||
|
// ====
|
||||||
|
// Base
|
||||||
|
// ====
|
||||||
|
|
||||||
|
export enum QueryType {
|
||||||
|
Event = 'event-query',
|
||||||
|
Recording = 'recording-query',
|
||||||
|
RecordingSegments = 'recording-segments-query',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum QueryResultsType {
|
||||||
|
Event = 'event-results',
|
||||||
|
Recording = 'recording-results',
|
||||||
|
RecordingSegments = 'recording-segments-results',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Engine {
|
||||||
|
Frigate = 'frigate',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataQuery {
|
||||||
|
type: QueryType;
|
||||||
|
cameraID: string;
|
||||||
|
}
|
||||||
|
export type PartialDataQuery = Partial<DataQuery>;
|
||||||
|
|
||||||
|
export interface TimeBasedDataQuery {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitedDataQuery {
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaQuery
|
||||||
|
extends DataQuery,
|
||||||
|
Partial<TimeBasedDataQuery>,
|
||||||
|
Partial<LimitedDataQuery> {}
|
||||||
|
|
||||||
|
export interface QueryResults {
|
||||||
|
type: QueryResultsType;
|
||||||
|
engine: Engine;
|
||||||
|
expiry?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryReturnType<QT> = QT extends EventQuery
|
||||||
|
? EventQueryResults
|
||||||
|
: QT extends RecordingQuery
|
||||||
|
? RecordingQueryResults
|
||||||
|
: QT extends RecordingSegmentsQuery
|
||||||
|
? RecordingSegmentsQueryResults
|
||||||
|
: never;
|
||||||
|
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
||||||
|
? EventQuery
|
||||||
|
: PQT extends PartialRecordingQuery
|
||||||
|
? RecordingQuery
|
||||||
|
: PQT extends PartialRecordingSegmentsQuery
|
||||||
|
? RecordingSegmentsQuery
|
||||||
|
: never;
|
||||||
|
|
||||||
|
// ===========
|
||||||
|
// Event Query
|
||||||
|
// ===========
|
||||||
|
|
||||||
|
export interface EventQuery extends MediaQuery {
|
||||||
|
type: QueryType.Event;
|
||||||
|
|
||||||
|
// Frigate equivalent: has_snapshot
|
||||||
|
hasSnapshot?: boolean;
|
||||||
|
|
||||||
|
// Frigate equivalent: has_clip
|
||||||
|
hasClip?: boolean;
|
||||||
|
|
||||||
|
// Frigate equivalent: label
|
||||||
|
what?: string;
|
||||||
|
|
||||||
|
// Frigate equivalent: zone
|
||||||
|
where?: string;
|
||||||
|
}
|
||||||
|
export type PartialEventQuery = Partial<EventQuery>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface EventQueryResults extends QueryResults {
|
||||||
|
type: QueryResultsType.Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============
|
||||||
|
// Recording Query
|
||||||
|
// ===============
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface RecordingQuery extends MediaQuery {
|
||||||
|
type: QueryType.Recording;
|
||||||
|
}
|
||||||
|
export type PartialRecordingQuery = Partial<RecordingQuery>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface RecordingQueryResults extends QueryResults {
|
||||||
|
type: QueryResultsType.Recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Recording Segments Query
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
|
||||||
|
type: QueryType.RecordingSegments;
|
||||||
|
}
|
||||||
|
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
||||||
|
//export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery> & { type: QueryType.RecordingSegments };
|
||||||
|
|
||||||
|
export interface RecordingSegmentsQueryResults extends QueryResults {
|
||||||
|
type: QueryResultsType.RecordingSegments;
|
||||||
|
segments: RecordingSegments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Frigate concrete results
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
export interface FrigateEventQueryResults extends EventQueryResults {
|
||||||
|
engine: Engine.Frigate;
|
||||||
|
events: FrigateEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrigateRecordingQueryResults extends RecordingQueryResults {
|
||||||
|
engine: Engine.Frigate;
|
||||||
|
recordings: FrigateRecording[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrigateRecordingSegmentsQueryResults
|
||||||
|
extends RecordingSegmentsQueryResults {
|
||||||
|
engine: Engine.Frigate;
|
||||||
|
}
|
||||||
+35
-80
@@ -1,19 +1,15 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
|
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
|
||||||
import differenceInHours from 'date-fns/differenceInHours';
|
|
||||||
import differenceInMinutes from 'date-fns/differenceInMinutes';
|
|
||||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
|
||||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import {
|
import {
|
||||||
BrowseRecordingQueryParameters,
|
|
||||||
ClipsOrSnapshots,
|
ClipsOrSnapshots,
|
||||||
ExtendedHomeAssistant,
|
|
||||||
FrigateCardError,
|
FrigateCardError,
|
||||||
FrigateEvent,
|
FrigateEvent,
|
||||||
FrigateEvents,
|
FrigateEvents,
|
||||||
frigateEventsSchema,
|
frigateEventsSchema,
|
||||||
|
FrigateRecording,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||||
import { homeAssistantWSRequest } from './ha';
|
import { homeAssistantWSRequest } from './ha';
|
||||||
@@ -63,6 +59,8 @@ const recordingSegmentSchema = z.object({
|
|||||||
end_time: z.number(),
|
end_time: z.number(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
export type RecordingSegment = z.infer<typeof recordingSegmentSchema>;
|
||||||
|
|
||||||
const recordingSegmentsSchema = recordingSegmentSchema.array();
|
const recordingSegmentsSchema = recordingSegmentSchema.array();
|
||||||
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
|
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
|
||||||
|
|
||||||
@@ -80,7 +78,7 @@ export type RetainResult = z.infer<typeof retainResultSchema>;
|
|||||||
* @returns A RecordingSummary object.
|
* @returns A RecordingSummary object.
|
||||||
*/
|
*/
|
||||||
export const getRecordingsSummary = async (
|
export const getRecordingsSummary = async (
|
||||||
hass: ExtendedHomeAssistant,
|
hass: HomeAssistant,
|
||||||
client_id: string,
|
client_id: string,
|
||||||
camera_name: string,
|
camera_name: string,
|
||||||
): Promise<RecordingSummary> => {
|
): Promise<RecordingSummary> => {
|
||||||
@@ -96,31 +94,29 @@ export const getRecordingsSummary = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface NativeFrigateRecordingSegmentsQuery {
|
||||||
|
instance_id: string;
|
||||||
|
camera: string;
|
||||||
|
after: number;
|
||||||
|
before: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the recording segments. May throw.
|
* Get the recording segments. May throw.
|
||||||
* @param hass The Home Assistant object.
|
* @param hass The Home Assistant object.
|
||||||
* @param client_id The Frigate client_id.
|
* @param params The recording segment query parameters.
|
||||||
* @param camera_name The Frigate camera name.
|
|
||||||
* @param before The segment low watermark.
|
|
||||||
* @param after The segment high watermark.
|
|
||||||
* @returns A RecordingSegments object.
|
* @returns A RecordingSegments object.
|
||||||
*/
|
*/
|
||||||
export const getRecordingSegments = async (
|
export const getRecordingSegments = async (
|
||||||
hass: ExtendedHomeAssistant,
|
hass: HomeAssistant,
|
||||||
client_id: string,
|
params: NativeFrigateRecordingSegmentsQuery,
|
||||||
camera_name: string,
|
|
||||||
before: Date,
|
|
||||||
after: Date,
|
|
||||||
): Promise<RecordingSegments> => {
|
): Promise<RecordingSegments> => {
|
||||||
return await homeAssistantWSRequest(
|
return await homeAssistantWSRequest(
|
||||||
hass,
|
hass,
|
||||||
recordingSegmentsSchema,
|
recordingSegmentsSchema,
|
||||||
{
|
{
|
||||||
type: 'frigate/recordings/get',
|
type: 'frigate/recordings/get',
|
||||||
instance_id: client_id,
|
...params,
|
||||||
camera: camera_name,
|
|
||||||
before: Math.floor(before.getTime() / 1000),
|
|
||||||
after: Math.ceil(after.getTime() / 1000),
|
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -159,7 +155,7 @@ export async function retainEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FrigateGetEventsParameters {
|
export interface NativeFrigateEventQuery {
|
||||||
instance_id?: string;
|
instance_id?: string;
|
||||||
camera?: string;
|
camera?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -179,7 +175,7 @@ export interface FrigateGetEventsParameters {
|
|||||||
*/
|
*/
|
||||||
export const getEvents = async (
|
export const getEvents = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
params?: FrigateGetEventsParameters,
|
params?: NativeFrigateEventQuery,
|
||||||
): Promise<FrigateEvents> => {
|
): Promise<FrigateEvents> => {
|
||||||
return await homeAssistantWSRequest(
|
return await homeAssistantWSRequest(
|
||||||
hass,
|
hass,
|
||||||
@@ -192,29 +188,6 @@ export const getEvents = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get multiple sets of events.
|
|
||||||
* @param hass The Home Assistant object.
|
|
||||||
* @param params A Map of parameters keyed on any key.
|
|
||||||
* @returns A Map of key -> events.
|
|
||||||
*/
|
|
||||||
export const getEventsMultiple = async <T>(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
params: Map<T, FrigateGetEventsParameters>,
|
|
||||||
): Promise<Map<T, FrigateEvents>> => {
|
|
||||||
const output: Map<T, FrigateEvents> = new Map();
|
|
||||||
const getEventsAndStore = async (
|
|
||||||
key: T,
|
|
||||||
param: FrigateGetEventsParameters,
|
|
||||||
): Promise<void> => {
|
|
||||||
output.set(key, await getEvents(hass, param));
|
|
||||||
};
|
|
||||||
await Promise.all(
|
|
||||||
Array.from(params).map(([key, param]) => getEventsAndStore(key, param)),
|
|
||||||
);
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an event generate a title.
|
* Given an event generate a title.
|
||||||
* @param event
|
* @param event
|
||||||
@@ -233,6 +206,12 @@ export const getEventTitle = (event: FrigateEvent): string => {
|
|||||||
)}%]`;
|
)}%]`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getRecordingTitle = (recording: FrigateRecording): string => {
|
||||||
|
return `${prettifyTitle(recording.camera)} ${formatDateAndTime(
|
||||||
|
fromUnixTime(recording.start_time),
|
||||||
|
)}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a thumbnail URL for an event.
|
* Get a thumbnail URL for an event.
|
||||||
* @param clientId The Frigate client id.
|
* @param clientId The Frigate client id.
|
||||||
@@ -254,10 +233,10 @@ export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): str
|
|||||||
export const getEventMediaContentID = (
|
export const getEventMediaContentID = (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
cameraName: string,
|
cameraName: string,
|
||||||
id: string,
|
event: FrigateEvent,
|
||||||
mediaType: ClipsOrSnapshots,
|
mediaType: ClipsOrSnapshots,
|
||||||
): string => {
|
): string => {
|
||||||
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${id}`;
|
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,43 +246,19 @@ export const getEventMediaContentID = (
|
|||||||
* @returns A recording identifier.
|
* @returns A recording identifier.
|
||||||
*/
|
*/
|
||||||
export const getRecordingMediaContentID = (
|
export const getRecordingMediaContentID = (
|
||||||
params: BrowseRecordingQueryParameters,
|
clientId: string,
|
||||||
|
cameraName: string,
|
||||||
|
recording: FrigateRecording,
|
||||||
): string => {
|
): string => {
|
||||||
|
const date = fromUnixTime(recording.start_time);
|
||||||
return [
|
return [
|
||||||
'media-source://frigate',
|
'media-source://frigate',
|
||||||
params.clientId,
|
clientId,
|
||||||
'recordings',
|
'recordings',
|
||||||
`${params.year}-${String(params.month).padStart(2, '0')}`,
|
cameraName,
|
||||||
String(params.day).padStart(2, '0'),
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
String(params.hour).padStart(2, '0'),
|
String(date.getDate()).padStart(2, '0'),
|
||||||
params.cameraName,
|
)}`,
|
||||||
|
String(date.getHours()).padStart(2, '0'),
|
||||||
].join('/');
|
].join('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience function to convert a timestamp to hours, minutes and seconds
|
|
||||||
* string. Heavily inspired by, and returning the same format as, the Frigate
|
|
||||||
* UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
|
||||||
* @param event The Frigate event.
|
|
||||||
* @returns A duration string.
|
|
||||||
*/
|
|
||||||
export function getEventDurationString(event: FrigateEvent): string {
|
|
||||||
if (!event.end_time) {
|
|
||||||
return localize('event.in_progress');
|
|
||||||
}
|
|
||||||
const start = fromUnixTime(event.start_time);
|
|
||||||
const end = fromUnixTime(event.end_time);
|
|
||||||
const hours = differenceInHours(end, start);
|
|
||||||
const minutes = differenceInMinutes(end, start) - hours * 60;
|
|
||||||
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
|
||||||
let duration = '';
|
|
||||||
|
|
||||||
if (hours) {
|
|
||||||
duration += `${hours}h `;
|
|
||||||
}
|
|
||||||
if (minutes) {
|
|
||||||
duration += `${minutes}m `;
|
|
||||||
}
|
|
||||||
duration += `${seconds}s`;
|
|
||||||
return duration;
|
|
||||||
}
|
|
||||||
|
|||||||
+12
-117
@@ -1,11 +1,6 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { ViewContext } from 'view';
|
|
||||||
import { homeAssistantWSRequest } from '.';
|
import { homeAssistantWSRequest } from '.';
|
||||||
import {
|
import { dispatchErrorMessageEvent } from '../../components/message.js';
|
||||||
dispatchErrorMessageEvent,
|
|
||||||
dispatchFrigateCardErrorEvent,
|
|
||||||
dispatchMessageEvent,
|
|
||||||
} from '../../components/message.js';
|
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import {
|
import {
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
@@ -14,7 +9,6 @@ import {
|
|||||||
ClipsOrSnapshots,
|
ClipsOrSnapshots,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
frigateBrowseMediaSourceSchema,
|
frigateBrowseMediaSourceSchema,
|
||||||
FrigateCardError,
|
|
||||||
FrigateEvent,
|
FrigateEvent,
|
||||||
FrigateRecording,
|
FrigateRecording,
|
||||||
MEDIA_CLASS_PLAYLIST,
|
MEDIA_CLASS_PLAYLIST,
|
||||||
@@ -22,7 +16,6 @@ import {
|
|||||||
MEDIA_TYPE_PLAYLIST,
|
MEDIA_TYPE_PLAYLIST,
|
||||||
MEDIA_TYPE_VIDEO,
|
MEDIA_TYPE_VIDEO,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { View } from '../../view.js';
|
|
||||||
import { getAllDependentCameras, getCameraTitle } from '../camera.js';
|
import { getAllDependentCameras, getCameraTitle } from '../camera.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,7 +112,7 @@ const browseMediaQuery = async (
|
|||||||
if (params.cameraID) {
|
if (params.cameraID) {
|
||||||
result.children?.forEach((child: FrigateBrowseMediaSource) => {
|
result.children?.forEach((child: FrigateBrowseMediaSource) => {
|
||||||
(child.frigate ??= {}).cameraID = params.cameraID;
|
(child.frigate ??= {}).cameraID = params.cameraID;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -185,7 +178,10 @@ export const mergeFrigateBrowseMediaSources = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return createEventParentForChildren('Merged events', children.sort(sortYoungestToOldest));
|
return createEventParentForChildren(
|
||||||
|
'Merged events',
|
||||||
|
children.sort(sortYoungestToOldest),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,81 +286,6 @@ export const getFullDependentBrowseMediaQueryParametersOrDispatchError = (
|
|||||||
return params;
|
return params;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the latest media and dispatch a change view event to reflect the
|
|
||||||
* results. If no media is found a suitable message event will be triggered
|
|
||||||
* instead.
|
|
||||||
* @param element The HTMLElement to dispatch events from.
|
|
||||||
* @param hass The Home Assistant object.
|
|
||||||
* @param view The current view to evolve.
|
|
||||||
* @param browseMediaQueryParameters The media parameters to query with.
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const fetchLatestMediaAndDispatchViewChange = async (
|
|
||||||
element: HTMLElement,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
view: Readonly<View>,
|
|
||||||
browseMediaQueryParameters: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
|
|
||||||
): Promise<void> => {
|
|
||||||
let parent: FrigateBrowseMediaSource | null;
|
|
||||||
try {
|
|
||||||
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
|
|
||||||
} catch (e) {
|
|
||||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
|
||||||
}
|
|
||||||
const childIndex = getFirstTrueMediaChildIndex(parent);
|
|
||||||
if (!parent || !parent.children || childIndex == null) {
|
|
||||||
return dispatchMessageEvent(
|
|
||||||
element,
|
|
||||||
view.isClipRelatedView()
|
|
||||||
? localize('common.no_clip')
|
|
||||||
: localize('common.no_snapshot'),
|
|
||||||
'info',
|
|
||||||
{
|
|
||||||
icon: view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
view
|
|
||||||
.evolve({
|
|
||||||
target: parent,
|
|
||||||
childIndex: childIndex,
|
|
||||||
})
|
|
||||||
.dispatchChangeEvent(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change
|
|
||||||
* view event to reflect the results.
|
|
||||||
* @param node The HTMLElement to dispatch events from.
|
|
||||||
* @param hass The Home Assistant object.
|
|
||||||
* @param view The current view to evolve.
|
|
||||||
* @param child The FrigateBrowseMediaSource child to query for.
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const fetchChildMediaAndDispatchViewChange = async (
|
|
||||||
element: HTMLElement,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
view: Readonly<View>,
|
|
||||||
child: Readonly<FrigateBrowseMediaSource>,
|
|
||||||
context?: ViewContext,
|
|
||||||
): Promise<void> => {
|
|
||||||
let parent: FrigateBrowseMediaSource;
|
|
||||||
try {
|
|
||||||
parent = await browseMedia(hass, child.media_content_id);
|
|
||||||
} catch (e) {
|
|
||||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
|
||||||
}
|
|
||||||
|
|
||||||
view
|
|
||||||
.evolve({
|
|
||||||
target: parent,
|
|
||||||
})
|
|
||||||
.mergeInContext(context)
|
|
||||||
.dispatchChangeEvent(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an array of media children, create a parent for them.
|
* Given an array of media children, create a parent for them.
|
||||||
* @param title The title to use for the parent.
|
* @param title The title to use for the parent.
|
||||||
@@ -402,7 +323,7 @@ export const createChild = (
|
|||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
recording?: FrigateRecording;
|
recording?: FrigateRecording;
|
||||||
event?: FrigateEvent;
|
event?: FrigateEvent;
|
||||||
cameraID?: string,
|
cameraID?: string;
|
||||||
},
|
},
|
||||||
): FrigateBrowseMediaSource => {
|
): FrigateBrowseMediaSource => {
|
||||||
const result: FrigateBrowseMediaSource = {
|
const result: FrigateBrowseMediaSource = {
|
||||||
@@ -413,10 +334,10 @@ export const createChild = (
|
|||||||
can_play: true,
|
can_play: true,
|
||||||
can_expand: false,
|
can_expand: false,
|
||||||
thumbnail: options?.thumbnail ?? null,
|
thumbnail: options?.thumbnail ?? null,
|
||||||
children: null
|
children: null,
|
||||||
}
|
};
|
||||||
if (options?.recording || options?.cameraID || options?.event) {
|
if (options?.recording || options?.cameraID || options?.event) {
|
||||||
result.frigate = {}
|
result.frigate = {};
|
||||||
if (options?.event) {
|
if (options?.event) {
|
||||||
result.frigate.event = options.event;
|
result.frigate.event = options.event;
|
||||||
}
|
}
|
||||||
@@ -443,38 +364,12 @@ export const sortYoungestToOldest = (
|
|||||||
const a_source = a.frigate?.event ?? a.frigate?.recording;
|
const a_source = a.frigate?.event ?? a.frigate?.recording;
|
||||||
const b_source = b.frigate?.event ?? b.frigate?.recording;
|
const b_source = b.frigate?.event ?? b.frigate?.recording;
|
||||||
|
|
||||||
if (
|
if (!a_source || (b_source && b_source.start_time > a_source.start_time)) {
|
||||||
!a_source ||
|
|
||||||
(b_source && b_source.start_time > a_source.start_time)
|
|
||||||
) {
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!b_source || (a_source && b_source.start_time < a_source.start_time)) {
|
||||||
!b_source ||
|
|
||||||
(a_source && b_source.start_time < a_source.start_time)
|
|
||||||
) {
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* 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.cameraName,
|
|
||||||
`${params.year}-${String(params.month).padStart(2, '0')}-${String(
|
|
||||||
params.day,
|
|
||||||
).padStart(2, '0')}`,
|
|
||||||
String(params.hour).padStart(2, '0'),
|
|
||||||
].join('/');
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import QuickLRU from 'quick-lru';
|
import QuickLRU from 'quick-lru';
|
||||||
import { homeAssistantWSRequest } from '.';
|
import { homeAssistantWSRequest } from '.';
|
||||||
import {
|
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
ResolvedMedia,
|
|
||||||
resolvedMediaSchema,
|
|
||||||
} from '../../types.js';
|
|
||||||
import { errorToConsole } from '../basic';
|
import { errorToConsole } from '../basic';
|
||||||
|
|
||||||
// It's important the cache size be at least as large as the largest likely
|
// It's important the cache size be at least as large as the largest likely
|
||||||
@@ -53,25 +49,22 @@ export class ResolvedMediaCache {
|
|||||||
/**
|
/**
|
||||||
* Resolve a given media source item.
|
* Resolve a given media source item.
|
||||||
* @param hass The Home Assistant object.
|
* @param hass The Home Assistant object.
|
||||||
* @param mediaSource The media source object.
|
* @param mediaContentID The media content ID.
|
||||||
* @param cache An optional ResolvedMediaCache object.
|
* @param cache An optional ResolvedMediaCache object.
|
||||||
* @returns The resolved media or `null`.
|
* @returns The resolved media or `null`.
|
||||||
*/
|
*/
|
||||||
export const resolveMedia = async (
|
export const resolveMedia = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mediaSource?: FrigateBrowseMediaSource,
|
mediaContentID: string,
|
||||||
cache?: ResolvedMediaCache,
|
cache?: ResolvedMediaCache,
|
||||||
): Promise<ResolvedMedia | null> => {
|
): Promise<ResolvedMedia | null> => {
|
||||||
if (!mediaSource) {
|
const cachedValue = cache ? cache.get(mediaContentID) : undefined;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
|
|
||||||
if (cachedValue) {
|
if (cachedValue) {
|
||||||
return cachedValue;
|
return cachedValue;
|
||||||
}
|
}
|
||||||
const request = {
|
const request = {
|
||||||
type: 'media_source/resolve_media',
|
type: 'media_source/resolve_media',
|
||||||
media_content_id: mediaSource.media_content_id,
|
media_content_id: mediaContentID,
|
||||||
};
|
};
|
||||||
let resolvedMedia: ResolvedMedia | null = null;
|
let resolvedMedia: ResolvedMedia | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -80,7 +73,7 @@ export const resolveMedia = async (
|
|||||||
errorToConsole(e as Error);
|
errorToConsole(e as Error);
|
||||||
}
|
}
|
||||||
if (cache && resolvedMedia) {
|
if (cache && resolvedMedia) {
|
||||||
cache.set(mediaSource.media_content_id, resolvedMedia);
|
cache.set(mediaContentID, resolvedMedia);
|
||||||
}
|
}
|
||||||
return resolvedMedia;
|
return resolvedMedia;
|
||||||
};
|
};
|
||||||
|
|||||||
+175
-190
@@ -1,27 +1,73 @@
|
|||||||
import add from 'date-fns/add';
|
import add from 'date-fns/add';
|
||||||
import endOfHour from 'date-fns/endOfHour';
|
|
||||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
import getUnixTime from 'date-fns/getUnixTime';
|
|
||||||
import startOfHour from 'date-fns/startOfHour';
|
import startOfHour from 'date-fns/startOfHour';
|
||||||
import sub from 'date-fns/sub';
|
import sub from 'date-fns/sub';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { dispatchMessageEvent } from '../components/message';
|
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||||
import { localize } from '../localize/localize';
|
import { EventMediaQueries, RecordingMediaQueries, View } from '../view';
|
||||||
import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types';
|
import { RecordingSegments } from './frigate';
|
||||||
import { View } from '../view';
|
import { DataManager } from './data/data-manager';
|
||||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
import { getAllDependentCameras } from './camera.js';
|
||||||
import { getRecordingMediaContentID } from './frigate';
|
import { ViewMedia, ViewMediaClassifier } from '../view-media';
|
||||||
import {
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
createChild,
|
|
||||||
createEventParentForChildren,
|
export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||||
sortYoungestToOldest,
|
element: HTMLElement,
|
||||||
} from './ha/browse-media';
|
hass: HomeAssistant,
|
||||||
import {
|
dataManager: DataManager,
|
||||||
RecordingSegmentsItem,
|
cameras: Map<string, CameraConfig>,
|
||||||
sortOldestToYoungest,
|
view: View,
|
||||||
DataManager,
|
options?: {
|
||||||
} from './data-manager';
|
mediaType?: ClipsOrSnapshotsOrAll;
|
||||||
import { getAllDependentCameras, getTrueCameras } from './camera.js';
|
targetView?: FrigateCardView;
|
||||||
|
},
|
||||||
|
): Promise<void> => {
|
||||||
|
(
|
||||||
|
await createViewForEvents(hass, dataManager, cameras, view, {
|
||||||
|
...options,
|
||||||
|
limit: 50, // Capture the 50 most recent events.
|
||||||
|
})
|
||||||
|
).dispatchChangeEvent(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createViewForEvents = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
dataManager: DataManager,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
view: View,
|
||||||
|
options?: {
|
||||||
|
query?: EventMediaQueries;
|
||||||
|
cameraIDs?: Set<string>;
|
||||||
|
mediaType?: ClipsOrSnapshotsOrAll;
|
||||||
|
targetView?: FrigateCardView;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
): Promise<View> => {
|
||||||
|
let query: EventMediaQueries;
|
||||||
|
if (options?.query) {
|
||||||
|
query = options.query;
|
||||||
|
} else {
|
||||||
|
const cameraIDs: Set<string> = options?.cameraIDs
|
||||||
|
? options.cameraIDs
|
||||||
|
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||||
|
|
||||||
|
const queries = dataManager.generateDefaultEventQueries(cameraIDs, {
|
||||||
|
...(options?.limit && { limit: options.limit }),
|
||||||
|
...((!options?.mediaType || ['clips', 'all'].includes(options.mediaType)) && {
|
||||||
|
has_clip: true,
|
||||||
|
}),
|
||||||
|
...(options?.mediaType === 'snapshots' && { has_snapshot: true }),
|
||||||
|
});
|
||||||
|
query = new EventMediaQueries(queries);
|
||||||
|
}
|
||||||
|
const queryResults = await dataManager.executeMediaQuery(hass, query);
|
||||||
|
|
||||||
|
return view?.evolve({
|
||||||
|
view: options?.targetView,
|
||||||
|
query: query,
|
||||||
|
queryResults: queryResults,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the view to a recent recording.
|
* Change the view to a recent recording.
|
||||||
@@ -34,7 +80,7 @@ import { getAllDependentCameras, getTrueCameras } from './camera.js';
|
|||||||
*/
|
*/
|
||||||
export const changeViewToRecentRecordingForCameraAndDependents = async (
|
export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
hass: ExtendedHomeAssistant,
|
hass: HomeAssistant,
|
||||||
dataManager: DataManager,
|
dataManager: DataManager,
|
||||||
cameras: Map<string, CameraConfig>,
|
cameras: Map<string, CameraConfig>,
|
||||||
view: View,
|
view: View,
|
||||||
@@ -43,20 +89,19 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
|||||||
},
|
},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
(
|
||||||
await changeViewToRecording(element, hass, dataManager, cameras, view, {
|
await createViewForRecordings(hass, dataManager, cameras, view, {
|
||||||
...options,
|
...options,
|
||||||
|
// Fetch 7 days worth of recordings (including recordings that are for the
|
||||||
// Fetch 1 days worth of recordings (including recordings that are for the current hour).
|
// current hour).
|
||||||
cameraIDs: getAllDependentCameras(cameras, view.camera),
|
start: sub(now, { days: 7 }),
|
||||||
start: sub(now, { days: 1 }),
|
end: add(now, { hours: 1 }),
|
||||||
end: add(now, { hours: 1 }),
|
})
|
||||||
});
|
).dispatchChangeEvent(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the view to a recording.
|
* Create a view for recordings.
|
||||||
* @param element The element to dispatch the view change from.
|
|
||||||
* @param hass The Home Assistant object.
|
* @param hass The Home Assistant object.
|
||||||
* @param dataManager The datamanager to use for data access.
|
* @param dataManager The datamanager to use for data access.
|
||||||
* @param cameras The camera configurations.
|
* @param cameras The camera configurations.
|
||||||
@@ -65,9 +110,8 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
|||||||
* targetTime to seek to, a targetView to dispatch to and a set of cameraIDs to
|
* targetTime to seek to, a targetView to dispatch to and a set of cameraIDs to
|
||||||
* restrict to.
|
* restrict to.
|
||||||
*/
|
*/
|
||||||
export const changeViewToRecording = async (
|
export const createViewForRecordings = async (
|
||||||
element: HTMLElement,
|
hass: HomeAssistant,
|
||||||
hass: ExtendedHomeAssistant,
|
|
||||||
dataManager: DataManager,
|
dataManager: DataManager,
|
||||||
cameras: Map<string, CameraConfig>,
|
cameras: Map<string, CameraConfig>,
|
||||||
view: View,
|
view: View,
|
||||||
@@ -78,165 +122,104 @@ export const changeViewToRecording = async (
|
|||||||
start?: Date;
|
start?: Date;
|
||||||
end?: Date;
|
end?: Date;
|
||||||
},
|
},
|
||||||
): Promise<void> => {
|
): Promise<View> => {
|
||||||
if (options && options.start && options.end) {
|
|
||||||
await dataManager.fetchIfNecessary(element, hass, options.start, options.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cameraIDs: Set<string> = options?.cameraIDs
|
const cameraIDs: Set<string> = options?.cameraIDs
|
||||||
? options.cameraIDs
|
? options.cameraIDs
|
||||||
: new Set([view.camera]);
|
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||||
const children = createRecordingChildren(dataManager, cameras, cameraIDs, {
|
|
||||||
...(options?.start && options?.end && { start: options.start, end: options.end }),
|
const queries = dataManager.generateDefaultRecordingQueries(cameraIDs, {
|
||||||
|
...(options?.start && { start: options.start }),
|
||||||
|
...(options?.end && { end: options.end }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!children.length) {
|
const query = new RecordingMediaQueries(queries);
|
||||||
return dispatchMessageEvent(element, localize('common.no_recording'), 'info', {
|
const queryResults = await dataManager.executeMediaQuery(hass, query);
|
||||||
icon: 'mdi:album',
|
|
||||||
});
|
let viewerContext: ViewContext | undefined = {};
|
||||||
|
const mediaArray = queryResults?.getResults();
|
||||||
|
if (queryResults && mediaArray && options?.targetTime) {
|
||||||
|
queryResults.selectBestResult((media) =>
|
||||||
|
findClosestMediaIndex(media, options.targetTime as Date, cameraIDs),
|
||||||
|
);
|
||||||
|
viewerContext = await generateMediaViewerContext(
|
||||||
|
hass,
|
||||||
|
dataManager,
|
||||||
|
mediaArray,
|
||||||
|
options.targetTime,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewerContext = options?.targetTime
|
return (
|
||||||
? generateMediaViewerContextForChildren(dataManager, children, options.targetTime)
|
view
|
||||||
: {};
|
?.evolve({
|
||||||
const childIndex = options?.targetTime
|
view: options?.targetView ? options.targetView : 'recording',
|
||||||
? findChildIndex(children, options.targetTime, cameraIDs)
|
query: query,
|
||||||
: null;
|
queryResults: queryResults,
|
||||||
const child = childIndex !== null ? children[childIndex] ?? null : null;
|
})
|
||||||
|
.mergeInContext(viewerContext) ?? null
|
||||||
view
|
);
|
||||||
?.evolve({
|
|
||||||
view: options?.targetView ? options.targetView : 'recording',
|
|
||||||
target: createEventParentForChildren(localize('common.recordings'), children),
|
|
||||||
childIndex: childIndex ?? 0,
|
|
||||||
...(child?.frigate?.cameraID && { camera: child.frigate?.cameraID }),
|
|
||||||
})
|
|
||||||
.mergeInContext(viewerContext)
|
|
||||||
.dispatchChangeEvent(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create recording objects.
|
|
||||||
* @param dataManager The datamanager to use for data access.
|
|
||||||
* @param cameras The camera configurations.
|
|
||||||
* @param cameraIDs The camera IDs to include recordings for.
|
|
||||||
* @param options A specific window (start and end) to allow recordings for.
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const createRecordingChildren = (
|
|
||||||
dataManager: DataManager,
|
|
||||||
cameras: Map<string, CameraConfig>,
|
|
||||||
cameraIDs: Set<string>,
|
|
||||||
options?: {
|
|
||||||
start?: Date;
|
|
||||||
end?: Date;
|
|
||||||
},
|
|
||||||
): FrigateBrowseMediaSource[] => {
|
|
||||||
const children: FrigateBrowseMediaSource[] = [];
|
|
||||||
|
|
||||||
for (const cameraID of getTrueCameras(cameras, cameraIDs)) {
|
|
||||||
const config = cameras.get(cameraID) ?? null;
|
|
||||||
const recordingSummary = dataManager.getRecordingSummaryForCamera(cameraID);
|
|
||||||
if (!config?.frigate.camera_name || !recordingSummary) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dayData of recordingSummary) {
|
|
||||||
for (const hourData of dayData.hours) {
|
|
||||||
const hour = add(dayData.day, { hours: hourData.hour });
|
|
||||||
const startHour = startOfHour(hour);
|
|
||||||
const endHour = endOfHour(hour);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!options?.start || startHour >= options.start) &&
|
|
||||||
(!options?.end || endHour <= options.end)
|
|
||||||
) {
|
|
||||||
children.push(
|
|
||||||
createChild(
|
|
||||||
`${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`,
|
|
||||||
getRecordingMediaContentID({
|
|
||||||
clientId: config.frigate.client_id,
|
|
||||||
year: dayData.day.getFullYear(),
|
|
||||||
month: dayData.day.getMonth() + 1,
|
|
||||||
day: dayData.day.getDate(),
|
|
||||||
hour: hourData.hour,
|
|
||||||
cameraName: config.frigate.camera_name,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
recording: {
|
|
||||||
camera: config.frigate.camera_name,
|
|
||||||
start_time: getUnixTime(startHour),
|
|
||||||
end_time: getUnixTime(endHour),
|
|
||||||
events: hourData.events,
|
|
||||||
},
|
|
||||||
cameraID: cameraID,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sort the events by time (to align recordings for different cameras at the
|
|
||||||
// same time).
|
|
||||||
return children.sort(sortYoungestToOldest);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the media view context for a set of media children (used to set
|
* Generate the media view context for a set of media children (used to set
|
||||||
* seek times into each media item).
|
* seek times into each media item).
|
||||||
|
* @param hass The Home Assistant object.
|
||||||
* @param dataManager The datamanager to use for data access.
|
* @param dataManager The datamanager to use for data access.
|
||||||
* @param children The media children.
|
* @param media The media.
|
||||||
* @param targetTime The target time.
|
* @param targetTime The target time.
|
||||||
* @returns The ViewContext.
|
* @returns The ViewContext.
|
||||||
*/
|
*/
|
||||||
export const generateMediaViewerContextForChildren = (
|
export const generateMediaViewerContext = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
dataManager: DataManager,
|
dataManager: DataManager,
|
||||||
children: FrigateBrowseMediaSource[],
|
media: ViewMedia[],
|
||||||
targetTime: Date,
|
targetTime: Date,
|
||||||
): ViewContext => {
|
): Promise<ViewContext> => {
|
||||||
const seek = new Map();
|
const seek = new Map();
|
||||||
const segmentsDataset = dataManager.recordingSegments;
|
|
||||||
const hourStart = startOfHour(targetTime);
|
const hourStart = startOfHour(targetTime);
|
||||||
|
|
||||||
children.forEach((child, index) => {
|
for (const [index, child] of media.entries()) {
|
||||||
const source = child.frigate?.recording ?? child.frigate?.event;
|
if (!ViewMediaClassifier.isMediaWithStartEndTime(child)) {
|
||||||
if (source && source.end_time && child.frigate?.cameraID) {
|
continue;
|
||||||
const start = source.start_time * 1000;
|
}
|
||||||
const end = source.end_time * 1000;
|
|
||||||
let seekSeconds: number | null = null;
|
|
||||||
|
|
||||||
if (targetTime.getTime() >= start && targetTime.getTime() <= end) {
|
const start = child.getStartTime();
|
||||||
const segments = segmentsDataset.get({
|
const end = child.getEndTime();
|
||||||
filter: (segment) =>
|
let seekSeconds: number | null = null;
|
||||||
segment.cameraID === child.frigate?.cameraID &&
|
|
||||||
segment.start >= start &&
|
if (targetTime >= start && targetTime <= end) {
|
||||||
segment.end <= end,
|
const query = dataManager.generateDefaultRecordingSegmentsQueries(
|
||||||
order: sortOldestToYoungest,
|
child.getCameraID(),
|
||||||
});
|
{
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
},
|
||||||
|
)[0];
|
||||||
|
const segments = (await dataManager.getRecordingSegments(hass, query)).get(query);
|
||||||
|
|
||||||
|
if (segments) {
|
||||||
seekSeconds = getSeekTimeInSegments(
|
seekSeconds = getSeekTimeInSegments(
|
||||||
// Recordings start from the top of the hour.
|
// Recordings start from the top of the hour.
|
||||||
child.frigate.recording ? hourStart : fromUnixTime(source.start_time),
|
child.isRecording() ? hourStart : start,
|
||||||
targetTime,
|
targetTime,
|
||||||
segments,
|
segments.segments,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seekSeconds !== null) {
|
|
||||||
seek.set(index, {
|
|
||||||
seekSeconds: seekSeconds,
|
|
||||||
seekTime: targetTime.getTime() / 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if (seekSeconds !== null) {
|
||||||
|
seek.set(index, {
|
||||||
|
seekSeconds: seekSeconds,
|
||||||
|
seekTime: targetTime.getTime() / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
|
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the relevant recording child given a date target.
|
* Find the closest matching media object.
|
||||||
* @param children The FrigateBrowseMediaSource[] children. Must be sorted
|
* @param mediaArray The media. Must be sorted most recent first.
|
||||||
* most recent first.
|
|
||||||
* @param targetTime The target time used to find the relevant child.
|
* @param targetTime The target time used to find the relevant child.
|
||||||
* @param cameraIDs The camera IDs to search for.
|
* @param cameraIDs The camera IDs to search for.
|
||||||
* @param refPoint Whether to find based on the start or end of the
|
* @param refPoint Whether to find based on the start or end of the
|
||||||
@@ -244,8 +227,8 @@ export const generateMediaViewerContextForChildren = (
|
|||||||
* the best match.
|
* the best match.
|
||||||
* @returns The childindex or null if no matching child is found.
|
* @returns The childindex or null if no matching child is found.
|
||||||
*/
|
*/
|
||||||
export const findChildIndex = (
|
export const findClosestMediaIndex = (
|
||||||
children: FrigateBrowseMediaSource[],
|
mediaArray: ViewMedia[],
|
||||||
targetTime: Date,
|
targetTime: Date,
|
||||||
cameraIDs: Set<string>,
|
cameraIDs: Set<string>,
|
||||||
refPoint?: 'start' | 'end',
|
refPoint?: 'start' | 'end',
|
||||||
@@ -257,27 +240,28 @@ export const findChildIndex = (
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
for (let i = 0; i < children.length; ++i) {
|
for (let i = 0; i < mediaArray.length; ++i) {
|
||||||
const child = children[i];
|
const media = mediaArray[i];
|
||||||
if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) {
|
if (
|
||||||
const source = child.frigate.event ?? child.frigate.recording;
|
!cameraIDs.has(media.getCameraID()) ||
|
||||||
if (!source?.start_time || !source?.end_time) {
|
!ViewMediaClassifier.isMediaWithStartEndTime(media)
|
||||||
continue;
|
) {
|
||||||
}
|
continue;
|
||||||
const startTime = fromUnixTime(source.start_time);
|
}
|
||||||
const endTime = fromUnixTime(source.end_time);
|
|
||||||
|
|
||||||
if (startTime <= targetTime && endTime >= targetTime) {
|
const startTime = media.getStartTime();
|
||||||
if (!refPoint) {
|
const endTime = media.getEndTime();
|
||||||
return i;
|
|
||||||
}
|
if (startTime <= targetTime && endTime >= targetTime) {
|
||||||
const delta =
|
if (!refPoint) {
|
||||||
refPoint === 'end'
|
return i;
|
||||||
? endTime.getTime() - targetTime.getTime()
|
}
|
||||||
: targetTime.getTime() - startTime.getTime();
|
const delta =
|
||||||
if (!bestMatch || delta < bestMatch.delta) {
|
refPoint === 'end'
|
||||||
bestMatch = { index: i, delta: delta };
|
? endTime.getTime() - targetTime.getTime()
|
||||||
}
|
: targetTime.getTime() - startTime.getTime();
|
||||||
|
if (!bestMatch || delta < bestMatch.delta) {
|
||||||
|
bestMatch = { index: i, delta: delta };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +279,7 @@ export const findChildIndex = (
|
|||||||
const getSeekTimeInSegments = (
|
const getSeekTimeInSegments = (
|
||||||
startTime: Date,
|
startTime: Date,
|
||||||
targetTime: Date,
|
targetTime: Date,
|
||||||
segments: RecordingSegmentsItem[],
|
segments: RecordingSegments,
|
||||||
): number | null => {
|
): number | null => {
|
||||||
if (!segments.length) {
|
if (!segments.length) {
|
||||||
return null;
|
return null;
|
||||||
@@ -304,13 +288,14 @@ const getSeekTimeInSegments = (
|
|||||||
|
|
||||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
if (segment.start > targetTime.getTime()) {
|
const segmentStart = fromUnixTime(segment.start_time);
|
||||||
|
if (segmentStart > targetTime) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const start =
|
const segmentEnd = fromUnixTime(segment.end_time);
|
||||||
segment.start < startTime.getTime() ? startTime.getTime() : segment.start;
|
const start = segmentStart < startTime ? startTime : segmentStart;
|
||||||
const end = segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end;
|
const end = segmentEnd > targetTime ? targetTime : segmentEnd;
|
||||||
seekMilliseconds += end - start;
|
seekMilliseconds += end.getTime() - start.getTime();
|
||||||
}
|
}
|
||||||
return seekMilliseconds / 1000;
|
return seekMilliseconds / 1000;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import sub from 'date-fns/sub';
|
||||||
|
import { DataSet } from 'vis-data';
|
||||||
|
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||||
|
import { CameraConfig, ClipsOrSnapshotsOrAll } from '../types';
|
||||||
|
import { DataManager } from './data/data-manager';
|
||||||
|
import { EventQuery } from './data/data-types';
|
||||||
|
import { RecordingSegment, RecordingSegments } from './frigate';
|
||||||
|
import { capEndDate, convertRangeToCacheFriendlyTimes } from './data/data-manager-util';
|
||||||
|
import { EventMediaQueries } from '../view';
|
||||||
|
import { ViewMedia } from '../view-media';
|
||||||
|
import { compressRanges, MemoryRangeSet } from './data/data-manager-range';
|
||||||
|
import { ModifyInterface } from './basic';
|
||||||
|
|
||||||
|
// Allow timeline freshness to be at least this number of seconds out of date
|
||||||
|
// (caching times in the data-engine may increase the effective delay).
|
||||||
|
const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30;
|
||||||
|
|
||||||
|
// Number of seconds gap allowable in order to consider two recording segments
|
||||||
|
// to be consecutive. Some low performance cameras have trouble and without a
|
||||||
|
// generous allowance here the timeline may be littered with individual segments
|
||||||
|
// instead of clean recording blocks.
|
||||||
|
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
|
||||||
|
|
||||||
|
export interface FrigateCardTimelineItem extends TimelineItem {
|
||||||
|
// Use numbers to avoid significant volumes of Date object construction (for
|
||||||
|
// high-quantity recording segments).
|
||||||
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
media?: ViewMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineDataSource {
|
||||||
|
protected _dataManager: DataManager;
|
||||||
|
protected _dataset: DataSet<FrigateCardTimelineItem> = new DataSet();
|
||||||
|
|
||||||
|
// The ranges in which recordings have been calculated and added for.
|
||||||
|
protected _recordingRanges = new MemoryRangeSet();
|
||||||
|
|
||||||
|
protected _cameraIDs: Set<string>;
|
||||||
|
protected _mediaType: ClipsOrSnapshotsOrAll;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
dataManager: DataManager,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
media: ClipsOrSnapshotsOrAll,
|
||||||
|
) {
|
||||||
|
this._dataManager = dataManager;
|
||||||
|
this._cameraIDs = cameraIDs;
|
||||||
|
this._mediaType = media;
|
||||||
|
}
|
||||||
|
|
||||||
|
get dataset(): DataSet<FrigateCardTimelineItem> {
|
||||||
|
return this._dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearEvents(): void {
|
||||||
|
this._dataset.remove(
|
||||||
|
this._dataset.get({
|
||||||
|
filter: (item) => item.type !== 'background',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public rewriteEvent(id: IdType): void {
|
||||||
|
// Hack: For timeline uses of the event dataset clustering may not update
|
||||||
|
// unless the dataset changes, artifically update the dataset to ensure the
|
||||||
|
// newly selected item cannot be included in a cluster.
|
||||||
|
|
||||||
|
// Hack2: Cannot use `updateOnly` here, as vis-data loses the object
|
||||||
|
// prototype, see: https://github.com/visjs/vis-data/issues/997 . Instead,
|
||||||
|
// remove then add.
|
||||||
|
const item = this._dataset.get(id);
|
||||||
|
if (item) {
|
||||||
|
this._dataset.remove(id);
|
||||||
|
this._dataset.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refresh(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
window: TimelineWindow,
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this._refreshEvents(hass, cameras, window),
|
||||||
|
this._refreshRecordings(hass, window),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] {
|
||||||
|
const _window = convertRangeToCacheFriendlyTimes(window, {
|
||||||
|
endCap: true,
|
||||||
|
});
|
||||||
|
return this._dataManager.generateDefaultEventQueries(this._cameraIDs, {
|
||||||
|
start: _window.start,
|
||||||
|
end: _window.end,
|
||||||
|
...(this._mediaType === 'clips' && { hasClip: true }),
|
||||||
|
...(this._mediaType === 'snapshots' && { hasSnapshot: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _refreshEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
window: TimelineWindow,
|
||||||
|
): Promise<void> {
|
||||||
|
const query = new EventMediaQueries(this.getTimelineEventQueries(window));
|
||||||
|
const results = await this._dataManager.executeMediaQuery(hass, query);
|
||||||
|
for (const media of results?.getResults() ?? []) {
|
||||||
|
const endTime = media.getEndTime();
|
||||||
|
const startTime = media.getStartTime();
|
||||||
|
const id = media.getID(cameras.get(media.getCameraID()));
|
||||||
|
if (id && startTime) {
|
||||||
|
this._dataset.update({
|
||||||
|
id: id,
|
||||||
|
group: media.getCameraID(),
|
||||||
|
content: '',
|
||||||
|
media: media,
|
||||||
|
start: startTime.getTime(),
|
||||||
|
type: endTime ? 'range' : 'point',
|
||||||
|
...(endTime && { end: endTime.getTime() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _refreshRecordings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
window: TimelineWindow,
|
||||||
|
): Promise<void> {
|
||||||
|
type FrigateCardTimelineItemWithEnd = ModifyInterface<
|
||||||
|
FrigateCardTimelineItem,
|
||||||
|
{ end: number }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const convertSegmentToRecording = (
|
||||||
|
cameraID: string,
|
||||||
|
segment: RecordingSegment,
|
||||||
|
): FrigateCardTimelineItemWithEnd => {
|
||||||
|
return {
|
||||||
|
id: `recording-${cameraID}-${segment.id}`,
|
||||||
|
group: cameraID,
|
||||||
|
start: segment.start_time * 1000,
|
||||||
|
end: segment.end_time * 1000,
|
||||||
|
content: '',
|
||||||
|
type: 'background',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExistingRecordingsForCameraID = (
|
||||||
|
cameraID: string,
|
||||||
|
): FrigateCardTimelineItemWithEnd[] => {
|
||||||
|
return this._dataset.get({
|
||||||
|
filter: (item) =>
|
||||||
|
item.type == 'background' && item.group === cameraID && item.end !== undefined,
|
||||||
|
}) as FrigateCardTimelineItemWithEnd[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRecordingsForCameraID = (cameraID: string): void => {
|
||||||
|
this._dataset.remove(
|
||||||
|
this._dataset.get({
|
||||||
|
filter: (item) => item.type === 'background' && item.group === cameraID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRecordings = (recordings: FrigateCardTimelineItemWithEnd[]): void => {
|
||||||
|
this._dataset.add(recordings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate an end date that's slightly short of the current time to allow
|
||||||
|
// for caching up to the freshness tolerance.
|
||||||
|
const end = sub(capEndDate(window.end), {
|
||||||
|
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
||||||
|
});
|
||||||
|
const hasCoverage = this._recordingRanges.hasCoverage({
|
||||||
|
start: window.start,
|
||||||
|
end: end,
|
||||||
|
});
|
||||||
|
if (hasCoverage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, {
|
||||||
|
endCap: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queries = this._dataManager.generateDefaultRecordingSegmentsQueries(
|
||||||
|
this._cameraIDs,
|
||||||
|
{
|
||||||
|
start: cacheFriendlyWindow.start,
|
||||||
|
end: cacheFriendlyWindow.end,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await this._dataManager.getRecordingSegments(hass, queries);
|
||||||
|
|
||||||
|
const newSegments: Map<string, RecordingSegments> = new Map();
|
||||||
|
for (const [query, result] of results) {
|
||||||
|
let destination: RecordingSegments | undefined = newSegments.get(query.cameraID);
|
||||||
|
if (!destination) {
|
||||||
|
destination = [];
|
||||||
|
newSegments.set(query.cameraID, destination);
|
||||||
|
}
|
||||||
|
result.segments.forEach((segment) => destination?.push(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [cameraID, segments] of newSegments.entries()) {
|
||||||
|
const existingRecordings = getExistingRecordingsForCameraID(cameraID);
|
||||||
|
const mergedRecordings = existingRecordings.concat(
|
||||||
|
segments.map((segment) => convertSegmentToRecording(cameraID, segment)),
|
||||||
|
);
|
||||||
|
const compressedRecordings = compressRanges(
|
||||||
|
mergedRecordings,
|
||||||
|
TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS,
|
||||||
|
) as FrigateCardTimelineItemWithEnd[];
|
||||||
|
|
||||||
|
deleteRecordingsForCameraID(cameraID);
|
||||||
|
addRecordings(compressedRecordings);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._recordingRanges.add({ start: window.start, end: end });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
|
import {
|
||||||
|
BrowseMediaSource,
|
||||||
|
CameraConfig,
|
||||||
|
FrigateEvent,
|
||||||
|
FrigateRecording,
|
||||||
|
MEDIA_TYPE_IMAGE,
|
||||||
|
} from './types.js';
|
||||||
|
import { ModifyInterface } from './utils/basic.js';
|
||||||
|
import {
|
||||||
|
getEventMediaContentID,
|
||||||
|
getEventThumbnailURL,
|
||||||
|
getEventTitle,
|
||||||
|
getRecordingMediaContentID,
|
||||||
|
getRecordingTitle,
|
||||||
|
} from './utils/frigate.js';
|
||||||
|
|
||||||
|
export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
|
||||||
|
export type ViewMediaSourceType = FrigateEvent | FrigateRecording | BrowseMediaSource;
|
||||||
|
|
||||||
|
export class ViewMediaClassifier {
|
||||||
|
public static isFrigateMedia(
|
||||||
|
media: ViewMedia,
|
||||||
|
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
|
||||||
|
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
|
||||||
|
}
|
||||||
|
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
|
||||||
|
return media instanceof FrigateEventViewMedia;
|
||||||
|
}
|
||||||
|
public static isFrigateRecording(
|
||||||
|
media: ViewMedia,
|
||||||
|
): media is FrigateRecordingViewMedia {
|
||||||
|
return media instanceof FrigateRecordingViewMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typescript conveniences.
|
||||||
|
public static isMediaWithStartEndTime(media: ViewMedia): media is ModifyInterface<
|
||||||
|
ViewMedia,
|
||||||
|
{
|
||||||
|
getStartTime(): Date;
|
||||||
|
getEndTime(): Date;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return !!media.getStartTime() && !!media.getEndTime();
|
||||||
|
}
|
||||||
|
public static isMediaWithStartTime(media: ViewMedia): media is ModifyInterface<
|
||||||
|
ViewMedia,
|
||||||
|
{
|
||||||
|
getStartTime(): Date;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return !!media.getStartTime();
|
||||||
|
}
|
||||||
|
public static isMediaWithEndTime(media: ViewMedia): media is ModifyInterface<
|
||||||
|
ViewMedia,
|
||||||
|
{
|
||||||
|
getEndTime(): Date;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return !!media.getEndTime();
|
||||||
|
}
|
||||||
|
public static isMediaWithID(media: ViewMedia): media is ModifyInterface<
|
||||||
|
ViewMedia,
|
||||||
|
{
|
||||||
|
getID(): string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return !!media.getID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewMediaBase<T extends ViewMediaSourceType> {
|
||||||
|
protected _mediaType: ViewMediaType;
|
||||||
|
protected _cameraID: string;
|
||||||
|
protected _source: T;
|
||||||
|
|
||||||
|
constructor(mediaType: ViewMediaType, cameraID: string, source: T) {
|
||||||
|
this._mediaType = mediaType;
|
||||||
|
this._cameraID = cameraID;
|
||||||
|
this._source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEvent(): boolean {
|
||||||
|
return this._mediaType === 'clip' || this._mediaType === 'snapshot';
|
||||||
|
}
|
||||||
|
public isRecording(): boolean {
|
||||||
|
return this._mediaType === 'recording';
|
||||||
|
}
|
||||||
|
public isClip(): boolean {
|
||||||
|
return this._mediaType === 'clip';
|
||||||
|
}
|
||||||
|
public isSnapshot(): boolean {
|
||||||
|
return this._mediaType === 'snapshot';
|
||||||
|
}
|
||||||
|
public getContentType(): 'image' | 'video' {
|
||||||
|
return this._mediaType === 'snapshot' ? 'image' : 'video';
|
||||||
|
}
|
||||||
|
public getCameraID(): string {
|
||||||
|
return this._cameraID;
|
||||||
|
}
|
||||||
|
public getMediaType(): ViewMediaType {
|
||||||
|
return this._mediaType;
|
||||||
|
}
|
||||||
|
public isVideo(): boolean {
|
||||||
|
return this.isClip() || this.isRecording();
|
||||||
|
}
|
||||||
|
public getSource(): T {
|
||||||
|
return this._source;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getID(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getStartTime(): Date | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getEndTime(): Date | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getContentID(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getTitle(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getThumbnail(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public isGroupableWith(that: ViewMedia): boolean {
|
||||||
|
return (
|
||||||
|
this.getMediaType() === that.getMediaType() &&
|
||||||
|
isEqual(this.getWhere(), that.getWhere()) &&
|
||||||
|
isEqual(this.getWhat(), that.getWhat())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public isFavorite(): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets the favorite attribute (if any). This purely sets the media item as a
|
||||||
|
// favorite in JS.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public setFavorite(_favorite: boolean): void {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
public getWhat(): string[] | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getWhere(): string[] | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getScore(): number | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getEventCount(): number | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a 'public interface only' version of ViewMediaBase for use elsewhere
|
||||||
|
// (typescript struggles with the ViewMediaClassifier classification functions
|
||||||
|
// used above if the object has data elements).
|
||||||
|
export type ViewMedia = {
|
||||||
|
[P in keyof ViewMediaBase<ViewMediaSourceType>]: ViewMediaBase<ViewMediaSourceType>[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HomeAssistantBrowserViewMedia extends ViewMediaBase<BrowseMediaSource> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getID(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return this._source.media_content_id;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getContentID(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return this._source.media_content_id;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getTitle(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return this._source.title;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getThumbnail(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return this._source.thumbnail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FrigateEventViewMedia extends ViewMediaBase<FrigateEvent> {
|
||||||
|
public hasClip(): boolean {
|
||||||
|
return !!this._source.has_clip;
|
||||||
|
}
|
||||||
|
public getClipEquivalent(): ViewMedia | null {
|
||||||
|
if (!this.hasClip()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ViewMediaFactory.createViewMediaFromFrigateEvent(
|
||||||
|
'clip',
|
||||||
|
this._cameraID,
|
||||||
|
this._source,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public getStartTime(): Date {
|
||||||
|
return fromUnixTime(this._source.start_time);
|
||||||
|
}
|
||||||
|
public getEndTime(): Date | null {
|
||||||
|
return this._source.end_time ? fromUnixTime(this._source.end_time) : null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getID(_cameraConfig?: CameraConfig): string {
|
||||||
|
return this._source.id;
|
||||||
|
}
|
||||||
|
public getContentID(cameraConfig?: CameraConfig): string | null {
|
||||||
|
if (
|
||||||
|
!cameraConfig ||
|
||||||
|
!cameraConfig.frigate.client_id ||
|
||||||
|
!cameraConfig.frigate.camera_name
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getEventMediaContentID(
|
||||||
|
cameraConfig.frigate.client_id,
|
||||||
|
cameraConfig.frigate.camera_name,
|
||||||
|
this._source,
|
||||||
|
this.isClip() ? 'clips' : 'snapshots',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getTitle(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return getEventTitle(this._source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getThumbnail(cameraConfig?: CameraConfig): string | null {
|
||||||
|
if (cameraConfig?.frigate.client_id) {
|
||||||
|
return getEventThumbnailURL(cameraConfig.frigate.client_id, this._source);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public isFavorite(): boolean | null {
|
||||||
|
return this._source.retain_indefinitely ?? null;
|
||||||
|
}
|
||||||
|
public setFavorite(favorite: boolean): void {
|
||||||
|
this._source.retain_indefinitely = favorite;
|
||||||
|
}
|
||||||
|
public getWhat(): string[] | null {
|
||||||
|
return [this._source.label];
|
||||||
|
}
|
||||||
|
public getWhere(): string[] | null {
|
||||||
|
const zones = this._source.zones;
|
||||||
|
return zones.length ? zones : null;
|
||||||
|
}
|
||||||
|
public getScore(): number | null {
|
||||||
|
return this._source.top_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FrigateRecordingViewMedia extends ViewMediaBase<FrigateRecording> {
|
||||||
|
public getID(cameraConfig?: CameraConfig): string | null {
|
||||||
|
// ID name is derived from the real camera name (not CameraID) since the
|
||||||
|
// recordings for the same camera across multiple zones will be the same and
|
||||||
|
// can be dedup'd from this id.
|
||||||
|
if (cameraConfig) {
|
||||||
|
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
||||||
|
cameraConfig.frigate.camera_name ?? ''
|
||||||
|
}/${this._source.start_time}/${this._source.end_time}}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getStartTime(): Date {
|
||||||
|
return fromUnixTime(this._source.start_time);
|
||||||
|
}
|
||||||
|
public getEndTime(): Date {
|
||||||
|
return fromUnixTime(this._source.end_time);
|
||||||
|
}
|
||||||
|
public getContentID(cameraConfig?: CameraConfig): string | null {
|
||||||
|
if (
|
||||||
|
!cameraConfig ||
|
||||||
|
!cameraConfig.frigate.client_id ||
|
||||||
|
!cameraConfig.frigate.camera_name
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getRecordingMediaContentID(
|
||||||
|
cameraConfig.frigate.client_id,
|
||||||
|
cameraConfig.frigate.camera_name,
|
||||||
|
this._source,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getTitle(_cameraConfig?: CameraConfig): string | null {
|
||||||
|
return getRecordingTitle(this._source);
|
||||||
|
}
|
||||||
|
public getEventCount(): number {
|
||||||
|
return this._source.events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewMediaFactory {
|
||||||
|
static createViewMediaFromFrigateEvent(
|
||||||
|
type: 'clip' | 'snapshot',
|
||||||
|
cameraID: string,
|
||||||
|
event: FrigateEvent,
|
||||||
|
): ViewMedia | null {
|
||||||
|
if (
|
||||||
|
(type === 'clip' && event.has_clip) ||
|
||||||
|
(type === 'snapshot' && event.has_snapshot)
|
||||||
|
) {
|
||||||
|
return new FrigateEventViewMedia(type, cameraID, event);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createViewMediaFromFrigateRecording(
|
||||||
|
cameraID: string,
|
||||||
|
recording: FrigateRecording,
|
||||||
|
): ViewMedia | null {
|
||||||
|
return new FrigateRecordingViewMedia('recording', cameraID, recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createViewMediaFromBrowseMediaSource(
|
||||||
|
cameraID: string,
|
||||||
|
browseMedia: BrowseMediaSource,
|
||||||
|
): ViewMedia | null {
|
||||||
|
return new HomeAssistantBrowserViewMedia(
|
||||||
|
browseMedia.media_content_type === MEDIA_TYPE_IMAGE ? 'snapshot' : 'clip',
|
||||||
|
cameraID,
|
||||||
|
browseMedia,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+208
-28
@@ -1,18 +1,50 @@
|
|||||||
|
// TODO: Do I need getMediaType below?
|
||||||
|
// TODO: Improve data storage in data-manager to allow fetching by limit not time.
|
||||||
|
// TODO: Live should get most recent events regardless of when they were.
|
||||||
|
// TODO: Refactor thumbnailsControlSchema to all use the shortform for other thumbnail users beyond live.
|
||||||
|
// TODO: Should be able to set live media to 'all' and have it work.
|
||||||
|
// TODO: If I replace the indexdb backend with just a map / array, does it work? Might be better.
|
||||||
|
// TODO: limit param in recordings should do something
|
||||||
|
// TODO: Callers of all async methods of data-engine need to catch errors.
|
||||||
|
// TODO: Search for references to frigate.js and see where it's being called outside of the dataManager. Can I collapse some of those functions in?
|
||||||
|
// TODO: Are there elements of ViewMedia (e.g. getEventCount) that should be moved into subclasses (e.g. a recording subclass).
|
||||||
|
// TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript
|
||||||
|
// TODO: In MediaQueriesBase, do we need to generic? Just have T be a MediaQuery?
|
||||||
|
// TODO: Are areEventQueries and areRecordingQueries should be in a classifier to keep with the pattern used elsewhere.
|
||||||
|
// TODO: Callers to the creation of new views for events/recordings need to dispatch events themselves when none are found.
|
||||||
|
// TODO: Examine how much of utils/frigate.ts can be moved into the Frigate data engine.
|
||||||
|
// TODO: Add garbage collecting of segments not present in the recording summaries anymore.
|
||||||
|
// TODO: Do I need to dedup recordings? (i.e. multiple zones on same camera may need to be dedup'd somewhere before returning the view). The media getID() call may be useful for this.
|
||||||
|
// TODO: Verify that scrolling the timeline will seek forward in both Frigate recordings & events.
|
||||||
|
// TODO: In generateMediaViewerContext there is an assumption that recordings start/end on the hour, which is true for Frigate but that assumption should be in the engine.
|
||||||
|
// TODO: Do a fresh media query in the viewer on snapshot click, since the first query may (e.g.) only have requested events with snapshots (which would miss an event with just a clip).
|
||||||
|
// TODO: In the viewer @click handlers should I use this.selected instead of calling carouselScrollPrevious()
|
||||||
|
// TODO: Implement seeking when the timeline is dragged.
|
||||||
|
// TODO: Can _timelineClickHandler be an async method in timeline-core to improve cleanliness?
|
||||||
|
// TODO: Can _timelineRangeChangedHandler be an async method in timeline-core to improve cleanliness?
|
||||||
|
// TODO: What should the timeline do when an event is clicked on that is not in the queryResults (or if queryResults is empty)?
|
||||||
|
// TODO: Should the timeline data source clear events (as it currently does) when the query changes?
|
||||||
|
// TODO: Implement gallery.
|
||||||
|
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
|
import clone from 'lodash-es/clone.js';
|
||||||
|
import cloneDeep from 'lodash-es/cloneDeep.js';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import {
|
import {
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
FrigateCardUserSpecifiedView,
|
FrigateCardUserSpecifiedView,
|
||||||
FrigateCardView,
|
FrigateCardView,
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
FRIGATE_CARD_VIEW_DEFAULT,
|
FRIGATE_CARD_VIEW_DEFAULT,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { dispatchFrigateCardEvent } from './utils/basic.js';
|
import { dispatchFrigateCardEvent } from './utils/basic.js';
|
||||||
|
import { EventQuery, MediaQuery, RecordingQuery } from './utils/data/data-types.js';
|
||||||
|
import { ViewMedia } from './view-media.js';
|
||||||
|
|
||||||
export interface ViewEvolveParameters {
|
export interface ViewEvolveParameters {
|
||||||
view?: FrigateCardView;
|
view?: FrigateCardView;
|
||||||
camera?: string;
|
camera?: string;
|
||||||
target?: FrigateBrowseMediaSource | null;
|
query?: MediaQueries | null;
|
||||||
childIndex?: number | null;
|
queryResults?: MediaQueriesResults | null;
|
||||||
context?: ViewContext | null;
|
context?: ViewContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,18 +53,170 @@ export interface ViewParameters extends ViewEvolveParameters {
|
|||||||
camera: string;
|
camera: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MediaQueriesBase<T extends MediaQuery> {
|
||||||
|
protected _queries: T[] | null = null;
|
||||||
|
|
||||||
|
protected constructor(queries?: T[]) {
|
||||||
|
if (queries) {
|
||||||
|
this._queries = queries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone(): MediaQueriesBase<T> {
|
||||||
|
return cloneDeep(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEqual(that: MediaQueries): boolean {
|
||||||
|
return isEqual(this.getQueries(), that.getQueries());
|
||||||
|
}
|
||||||
|
|
||||||
|
public areEventQueries(): this is EventMediaQueries {
|
||||||
|
return this instanceof EventMediaQueries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public areRecordingQueries(): this is RecordingMediaQueries {
|
||||||
|
return this instanceof RecordingMediaQueries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getQueries(): T[] | null {
|
||||||
|
return this._queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setQueries(queries: T[]): void {
|
||||||
|
this._queries = queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setQueriesTime(start: Date, end: Date) {
|
||||||
|
for (const query of this._queries ?? []) {
|
||||||
|
query.start = start;
|
||||||
|
query.end = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
|
||||||
|
constructor(queries?: EventQuery[]) {
|
||||||
|
super(queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public convertToClipsQueries(): void {
|
||||||
|
for (const query of this._queries ?? []) {
|
||||||
|
delete query.hasSnapshot;
|
||||||
|
query.hasClip = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone(): EventMediaQueries {
|
||||||
|
return cloneDeep(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordingMediaQueries extends MediaQueriesBase<RecordingQuery> {
|
||||||
|
constructor(queries?: RecordingQuery[]) {
|
||||||
|
super(queries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaQueries = EventMediaQueries | RecordingMediaQueries;
|
||||||
|
|
||||||
|
export class MediaQueriesResults {
|
||||||
|
protected _results: ViewMedia[] | null = null;
|
||||||
|
protected _resultsTimestamp: Date | null = null;
|
||||||
|
protected _selectedIndex: number | null = null;
|
||||||
|
|
||||||
|
constructor(results?: ViewMedia[], selectedIndex?: number) {
|
||||||
|
if (results) {
|
||||||
|
this.setResults(results);
|
||||||
|
}
|
||||||
|
if (selectedIndex !== undefined) {
|
||||||
|
this.selectResult(selectedIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone(): MediaQueriesResults {
|
||||||
|
// Shallow clone -- will reuse the same _results object (as there are no
|
||||||
|
// methods that support modification of the results themselves, and since
|
||||||
|
// changing the selectedIndex on a consistent set of results is a common
|
||||||
|
// operation).
|
||||||
|
return clone(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getResults(): ViewMedia[] | null {
|
||||||
|
return this._results;
|
||||||
|
}
|
||||||
|
public getResultsCount(): number {
|
||||||
|
return this._results?.length ?? 0;
|
||||||
|
}
|
||||||
|
public hasResults(): boolean {
|
||||||
|
return !!this._results;
|
||||||
|
}
|
||||||
|
public setResults(results: ViewMedia[]) {
|
||||||
|
this._results = results;
|
||||||
|
this._resultsTimestamp = new Date();
|
||||||
|
}
|
||||||
|
public getResult(index?: number): ViewMedia | null {
|
||||||
|
if (!this._results || index === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this._results[index];
|
||||||
|
}
|
||||||
|
public getSelectedResult(): ViewMedia | null {
|
||||||
|
return this._selectedIndex === null ? null : this.getResult(this._selectedIndex);
|
||||||
|
}
|
||||||
|
public getSelectedIndex(): number | null {
|
||||||
|
return this._selectedIndex;
|
||||||
|
}
|
||||||
|
public hasSelectedResult(): boolean {
|
||||||
|
return this.getSelectedResult() !== null;
|
||||||
|
}
|
||||||
|
public resetSelectedResult(): MediaQueriesResults {
|
||||||
|
this._selectedIndex = null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
public getResultsTimestamp(): Date | null {
|
||||||
|
return this._resultsTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public selectResult(index: number): MediaQueriesResults {
|
||||||
|
if (this._results && index >= 0 && index < this._results.length) {
|
||||||
|
this._selectedIndex = index;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults {
|
||||||
|
for (const [index, result] of this._results?.entries() ?? []) {
|
||||||
|
if (func(result)) {
|
||||||
|
this._selectedIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
public selectBestResult(
|
||||||
|
func: (media: ViewMedia[]) => number | null,
|
||||||
|
): MediaQueriesResults {
|
||||||
|
if (this._results) {
|
||||||
|
const resultIndex = func(this._results);
|
||||||
|
if (resultIndex !== null) {
|
||||||
|
this._selectedIndex = resultIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class View {
|
export class View {
|
||||||
public view: FrigateCardView;
|
public view: FrigateCardView;
|
||||||
public camera: string;
|
public camera: string;
|
||||||
public target: FrigateBrowseMediaSource | null;
|
public query: MediaQueries | null;
|
||||||
public childIndex: number | null;
|
public queryResults: MediaQueriesResults | null;
|
||||||
public context: ViewContext | null;
|
public context: ViewContext | null;
|
||||||
|
|
||||||
constructor(params: ViewParameters) {
|
constructor(params: ViewParameters) {
|
||||||
this.view = params.view;
|
this.view = params.view;
|
||||||
this.camera = params.camera;
|
this.camera = params.camera;
|
||||||
this.target = params.target ?? null;
|
this.query = params.query ?? null;
|
||||||
this.childIndex = params.childIndex ?? null;
|
this.queryResults = params.queryResults ?? null;
|
||||||
this.context = params.context ?? null;
|
this.context = params.context ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,10 +255,12 @@ export class View {
|
|||||||
!curr ||
|
!curr ||
|
||||||
prev.view !== curr.view ||
|
prev.view !== curr.view ||
|
||||||
prev.camera !== curr.camera ||
|
prev.camera !== curr.camera ||
|
||||||
// When in the live view, the target/childIndex are the events that
|
// When in the live view, the target contains the events that happened in
|
||||||
// happened in the past -- not reflective of the actual live media viewer.
|
// the past -- not reflective of the actual live media viewer.
|
||||||
(curr.view !== 'live' &&
|
(curr.view !== 'live' &&
|
||||||
(prev.target !== curr.target || prev.childIndex !== curr.childIndex))
|
(prev.queryResults !== curr.queryResults ||
|
||||||
|
prev.queryResults?.getSelectedResult() !==
|
||||||
|
curr.queryResults?.getSelectedResult()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +271,11 @@ export class View {
|
|||||||
return new View({
|
return new View({
|
||||||
view: this.view,
|
view: this.view,
|
||||||
camera: this.camera,
|
camera: this.camera,
|
||||||
target: this.target,
|
query: this.query?.clone() ?? null,
|
||||||
childIndex: this.childIndex,
|
queryResults: this.queryResults?.clone() ?? null,
|
||||||
|
// target: this.target,
|
||||||
|
// targetIndex: this.targetIndex,
|
||||||
|
// targetFingerprint: this.targetFingerprint,
|
||||||
context: this.context,
|
context: this.context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -100,8 +289,11 @@ export class View {
|
|||||||
return new View({
|
return new View({
|
||||||
view: params.view !== undefined ? params.view : this.view,
|
view: params.view !== undefined ? params.view : this.view,
|
||||||
camera: params.camera !== undefined ? params.camera : this.camera,
|
camera: params.camera !== undefined ? params.camera : this.camera,
|
||||||
target: params.target !== undefined ? params.target : this.target,
|
query: params.query !== undefined ? params.query : this.query?.clone() ?? null,
|
||||||
childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex,
|
queryResults:
|
||||||
|
params.queryResults !== undefined
|
||||||
|
? params.queryResults
|
||||||
|
: this.queryResults?.clone() ?? null,
|
||||||
context: params.context !== undefined ? params.context : this.context,
|
context: params.context !== undefined ? params.context : this.context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -123,7 +315,7 @@ export class View {
|
|||||||
*/
|
*/
|
||||||
public removeContext(key: keyof ViewContext): View {
|
public removeContext(key: keyof ViewContext): View {
|
||||||
if (this.context) {
|
if (this.context) {
|
||||||
delete(this.context[key]);
|
delete this.context[key];
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -188,7 +380,7 @@ export class View {
|
|||||||
/**
|
/**
|
||||||
* Determine if a view is related to a recording or recordings.
|
* Determine if a view is related to a recording or recordings.
|
||||||
*/
|
*/
|
||||||
public isRecordingRelatedView(): boolean {
|
public isRecordingRelatedView(): boolean {
|
||||||
return ['recording', 'recordings'].includes(this.view);
|
return ['recording', 'recordings'].includes(this.view);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,18 +399,6 @@ export class View {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the media item that should be played.
|
|
||||||
**/
|
|
||||||
get media(): FrigateBrowseMediaSource | null {
|
|
||||||
if (this.target) {
|
|
||||||
if (this.target.children && this.childIndex !== null) {
|
|
||||||
return this.target.children[this.childIndex] ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch an event to request a view change.
|
* Dispatch an event to request a view change.
|
||||||
* @param target The target dispatching the event.
|
* @param target The target dispatching the event.
|
||||||
|
|||||||
@@ -3167,7 +3167,7 @@ uuid@^8.3.2:
|
|||||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
||||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||||
|
|
||||||
vis-data@^7.1.3:
|
vis-data@^7.1.4:
|
||||||
version "7.1.4"
|
version "7.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/vis-data/-/vis-data-7.1.4.tgz#90e5e796a79e1901de14c0808fb32a1a0735c1dc"
|
resolved "https://registry.yarnpkg.com/vis-data/-/vis-data-7.1.4.tgz#90e5e796a79e1901de14c0808fb32a1a0735c1dc"
|
||||||
integrity sha512-usy+ePX1XnArNvJ5BavQod7YRuGQE1pjFl+pu7IS6rCom2EBoG0o1ZzCqf3l5US6MW51kYkLR+efxRbnjxNl7w==
|
integrity sha512-usy+ePX1XnArNvJ5BavQod7YRuGQE1pjFl+pu7IS6rCom2EBoG0o1ZzCqf3l5US6MW51kYkLR+efxRbnjxNl7w==
|
||||||
|
|||||||
Reference in New Issue
Block a user