Initial support for grid for live and media viewer.
This commit is contained in:
Vendored
+2
-1
@@ -5,5 +5,6 @@
|
|||||||
"i18n-ally.keepFulfilled": true,
|
"i18n-ally.keepFulfilled": true,
|
||||||
"i18n-ally.editor.preferEditor": true,
|
"i18n-ally.editor.preferEditor": true,
|
||||||
"i18n-ally.translate.saveAsCandidates": true,
|
"i18n-ally.translate.saveAsCandidates": true,
|
||||||
"vitest.commandLine": "npx vitest --root ."
|
"vitest.commandLine": "npx vitest --root .",
|
||||||
|
"diffEditor.experimental.useVersion2": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"lit": "^2.3.1",
|
"lit": "^2.3.1",
|
||||||
"lit-flatpickr": "^0.4.0",
|
"lit-flatpickr": "^0.4.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"masonry-layout": "^4.2.2",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"propagating-hammerjs": "^2.0.1",
|
"propagating-hammerjs": "^2.0.1",
|
||||||
"quick-lru": "^6.1.0",
|
"quick-lru": "^6.1.0",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||||
"@rollup/plugin-replace": "^4.0.0",
|
"@rollup/plugin-replace": "^4.0.0",
|
||||||
"@types/lodash-es": "^4.17.5",
|
"@types/lodash-es": "^4.17.5",
|
||||||
|
"@types/masonry-layout": "^4.2.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
||||||
"@typescript-eslint/parser": "^5.36.2",
|
"@typescript-eslint/parser": "^5.36.2",
|
||||||
"@vitest/coverage-c8": "^0.29.8",
|
"@vitest/coverage-c8": "^0.29.8",
|
||||||
|
|||||||
+66
-8
@@ -59,6 +59,7 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
MESSAGE_TYPE_PRIORITIES,
|
MESSAGE_TYPE_PRIORITIES,
|
||||||
RawFrigateCardConfig,
|
RawFrigateCardConfig,
|
||||||
|
ViewDisplayMode,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import {
|
||||||
convertActionToFrigateCardCustomAction,
|
convertActionToFrigateCardCustomAction,
|
||||||
@@ -97,6 +98,7 @@ import {
|
|||||||
} from './utils/substream';
|
} from './utils/substream';
|
||||||
import { Timer } from './utils/timer';
|
import { Timer } from './utils/timer';
|
||||||
import { getParseErrorPaths } from './utils/zod.js';
|
import { getParseErrorPaths } from './utils/zod.js';
|
||||||
|
import { ViewMediaClassifier } from './view/media-classifier';
|
||||||
import { View } from './view/view.js';
|
import { View } from './view/view.js';
|
||||||
|
|
||||||
/** A note on media callbacks:
|
/** A note on media callbacks:
|
||||||
@@ -186,6 +188,9 @@ class FrigateCard extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
protected _expand = false;
|
protected _expand = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
protected _viewDisplayMode?: ViewDisplayMode;
|
||||||
|
|
||||||
protected _microphoneController?: MicrophoneController;
|
protected _microphoneController?: MicrophoneController;
|
||||||
protected _conditionController?: ConditionController;
|
protected _conditionController?: ConditionController;
|
||||||
protected _automationsController?: AutomationsController;
|
protected _automationsController?: AutomationsController;
|
||||||
@@ -414,6 +419,7 @@ class FrigateCard extends LitElement {
|
|||||||
state: this._hass.states,
|
state: this._hass.states,
|
||||||
}),
|
}),
|
||||||
media_loaded: this._mediaLoadedInfoController.has(),
|
media_loaded: this._mediaLoadedInfoController.has(),
|
||||||
|
displayMode: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +472,7 @@ class FrigateCard extends LitElement {
|
|||||||
this._conditionController?.setState({
|
this._conditionController?.setState({
|
||||||
view: this._view.view,
|
view: this._view.view,
|
||||||
camera: this._view.camera,
|
camera: this._view.camera,
|
||||||
|
displayMode: this._view.displayMode ?? undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -493,10 +500,14 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cameraID) {
|
if (cameraID) {
|
||||||
|
const viewName = args?.viewName ?? this._getConfig().view.default;
|
||||||
|
const displayMode =
|
||||||
|
this._viewDisplayMode ?? this._getDefaultDisplayModeForView(viewName);
|
||||||
changeView(
|
changeView(
|
||||||
new View({
|
new View({
|
||||||
view: args?.viewName ?? this._getConfig().view.default,
|
view: viewName,
|
||||||
camera: cameraID,
|
camera: cameraID,
|
||||||
|
displayMode: displayMode,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -641,7 +652,10 @@ class FrigateCard extends LitElement {
|
|||||||
targetCamera &&
|
targetCamera &&
|
||||||
(this._view.camera !== targetCamera || !this._view.is('live'))
|
(this._view.camera !== targetCamera || !this._view.is('live'))
|
||||||
) {
|
) {
|
||||||
this._changeView({ view: new View({ view: 'live', camera: targetCamera }) });
|
this._changeView({
|
||||||
|
viewName: 'live',
|
||||||
|
cameraID: targetCamera,
|
||||||
|
});
|
||||||
changedCamera = true;
|
changedCamera = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1017,7 @@ class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
if (this._view.isViewerView() && media) {
|
if (this._view.isViewerView() && media) {
|
||||||
media_content_id = media.getContentID();
|
media_content_id = media.getContentID();
|
||||||
media_content_type = media.getContentType();
|
media_content_type = ViewMediaClassifier.isVideo(media) ? 'video' : 'image';
|
||||||
title = media.getTitle();
|
title = media.getTitle();
|
||||||
thumbnail = media.getThumbnail();
|
thumbnail = media.getThumbnail();
|
||||||
} else if (this._view?.is('live') && cameraEntity) {
|
} else if (this._view?.is('live') && cameraEntity) {
|
||||||
@@ -1049,6 +1063,24 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _getDefaultDisplayModeForView(view: FrigateCardView): ViewDisplayMode {
|
||||||
|
let mode: ViewDisplayMode | null = null;
|
||||||
|
switch (view) {
|
||||||
|
case 'clip':
|
||||||
|
case 'clips':
|
||||||
|
case 'recording':
|
||||||
|
case 'recordings':
|
||||||
|
case 'snapshot':
|
||||||
|
case 'snapshots':
|
||||||
|
mode = this._getConfig().media_viewer.display?.mode ?? null;
|
||||||
|
break;
|
||||||
|
case 'live':
|
||||||
|
mode = this._getConfig().live.display?.mode ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return mode ?? 'single';
|
||||||
|
}
|
||||||
|
|
||||||
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
|
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
|
||||||
// Note: This function needs to process (view-related) commands even when
|
// Note: This function needs to process (view-related) commands even when
|
||||||
// _view has not yet been initialized (since it may be used to set a view
|
// _view has not yet been initialized (since it may be used to set a view
|
||||||
@@ -1119,7 +1151,8 @@ class FrigateCard extends LitElement {
|
|||||||
? targetView
|
? targetView
|
||||||
: FRIGATE_CARD_VIEW_DEFAULT;
|
: FRIGATE_CARD_VIEW_DEFAULT;
|
||||||
this._changeView({
|
this._changeView({
|
||||||
view: new View({ view: actualView, camera: selectCameraID }),
|
viewName: actualView,
|
||||||
|
cameraID: selectCameraID,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1203,6 +1236,25 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'display_mode_select':
|
||||||
|
this._viewDisplayMode = frigateCardAction.display_mode;
|
||||||
|
this._conditionController?.setState({
|
||||||
|
displayMode: this._viewDisplayMode,
|
||||||
|
});
|
||||||
|
// If the new mode is for all cameras, but the current query does not
|
||||||
|
// have a query for every cameraID, reset it.
|
||||||
|
const resetQuery =
|
||||||
|
frigateCardAction.mode === 'grid' &&
|
||||||
|
!this._view?.query?.hasQueriesForCameraIDs(
|
||||||
|
this._cameraManager.getStore().getVisibleCameraIDs(),
|
||||||
|
);
|
||||||
|
this._changeView({
|
||||||
|
view: this._view?.evolve({
|
||||||
|
displayMode: frigateCardAction.display_mode,
|
||||||
|
...(resetQuery && { query: null, queryResults: null }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||||
}
|
}
|
||||||
@@ -1501,15 +1553,21 @@ class FrigateCard extends LitElement {
|
|||||||
? `${lastKnown.width} / ${lastKnown.height}`
|
? `${lastKnown.width} / ${lastKnown.height}`
|
||||||
: 'unset',
|
: 'unset',
|
||||||
);
|
);
|
||||||
// Non-media mays have no intrinsic dimensions and so we need to explicit
|
// Non-media may have no intrinsic dimensions (or multiple media items in a
|
||||||
// request the dialog to use all available space.
|
// grid) and so we need to explicit request the dialog to use all available
|
||||||
|
// space.
|
||||||
|
const isGrid = this._view?.isGrid();
|
||||||
this.style.setProperty(
|
this.style.setProperty(
|
||||||
'--frigate-card-expand-width',
|
'--frigate-card-expand-width',
|
||||||
this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-width)',
|
!isGrid && this._view?.isAnyMediaView()
|
||||||
|
? 'none'
|
||||||
|
: 'var(--frigate-card-expand-max-width)',
|
||||||
);
|
);
|
||||||
this.style.setProperty(
|
this.style.setProperty(
|
||||||
'--frigate-card-expand-height',
|
'--frigate-card-expand-height',
|
||||||
this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-height)',
|
!isGrid && this._view?.isAnyMediaView()
|
||||||
|
? 'none'
|
||||||
|
: 'var(--frigate-card-expand-max-height)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -368,7 +368,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
query: newMediaQueries,
|
query: newMediaQueries,
|
||||||
queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
|
queryResults: new MediaQueriesResults({
|
||||||
|
results: extension.results,
|
||||||
|
}).selectResultIfFound(
|
||||||
(media) => media === this.view?.queryResults?.getSelectedResult(),
|
(media) => media === this.view?.queryResults?.getSelectedResult(),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -468,7 +470,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
this.view
|
this.view
|
||||||
.evolve({
|
.evolve({
|
||||||
view: 'media',
|
view: 'media',
|
||||||
queryResults: this.view.queryResults?.clone().selectResult(
|
queryResults: this.view.queryResults?.clone().selectIndex(
|
||||||
// Media in the gallery is reversed vs the queryResults (see
|
// Media in the gallery is reversed vs the queryResults (see
|
||||||
// note above).
|
// note above).
|
||||||
this._media.length - index - 1,
|
this._media.length - index - 1,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import liveImageStyle from '../../scss/live-image.scss';
|
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||||
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
||||||
import '../image.js';
|
import '../image.js';
|
||||||
import { getStateObjOrDispatchError } from './live.js';
|
import { getStateObjOrDispatchError } from './live.js';
|
||||||
@@ -50,7 +50,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getScreenshotURL(): Promise<string | null> {
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
return await this._refImage.value?.getScreenshotURL() ?? null;
|
return (await this._refImage.value?.getScreenshotURL()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
@@ -77,7 +77,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(liveImageStyle);
|
return unsafeCSS(basicBlockStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+185
-47
@@ -16,12 +16,12 @@ import { guard } from 'lit/directives/guard.js';
|
|||||||
import { keyed } from 'lit/directives/keyed.js';
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
|
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||||
import liveStyle from '../../scss/live.scss';
|
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
@@ -56,6 +56,9 @@ import '../surround.js';
|
|||||||
import '../title-control.js';
|
import '../title-control.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 { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
|
||||||
|
import { getDefaultTitleConfigForView } from '../title-control.js';
|
||||||
|
|
||||||
interface LiveViewContext {
|
interface LiveViewContext {
|
||||||
// A cameraID override (used for dependencies/substreams to force a different
|
// A cameraID override (used for dependencies/substreams to force a different
|
||||||
@@ -126,7 +129,10 @@ export class FrigateCardLive extends LitElement {
|
|||||||
public view?: Readonly<View>;
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public liveConfig?: LiveConfig;
|
public nonOverriddenLiveConfig?: LiveConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public overriddenLiveConfig?: LiveConfig;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public liveOverrides?: LiveOverrides;
|
public liveOverrides?: LiveOverrides;
|
||||||
@@ -228,13 +234,18 @@ export class FrigateCardLive extends LitElement {
|
|||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) {
|
if (
|
||||||
|
!this.hass ||
|
||||||
|
!this.nonOverriddenLiveConfig ||
|
||||||
|
!this.cameraManager ||
|
||||||
|
!this.view
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes:
|
// Notes:
|
||||||
// - See use of liveConfig and not config below -- the carousel will
|
// - See use of liveConfig and not config below -- the underlying carousel
|
||||||
// independently override the liveConfig to reflect the camera in the
|
// will independently override the liveConfig to reflect the camera in the
|
||||||
// carousel (not necessarily the selected camera).
|
// carousel (not necessarily the selected camera).
|
||||||
// - Various events are captured to prevent them propagating upwards if the
|
// - Various events are captured to prevent them propagating upwards if the
|
||||||
// card is in the background.
|
// card is in the background.
|
||||||
@@ -244,10 +255,11 @@ export class FrigateCardLive extends LitElement {
|
|||||||
const result = html`${keyed(
|
const result = html`${keyed(
|
||||||
this._renderKey,
|
this._renderKey,
|
||||||
html`
|
html`
|
||||||
<frigate-card-live-carousel
|
<frigate-card-live-grid
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.liveConfig=${this.liveConfig}
|
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||||
|
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||||
.inBackground=${this._inBackground}
|
.inBackground=${this._inBackground}
|
||||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.liveOverrides=${this.liveOverrides}
|
.liveOverrides=${this.liveOverrides}
|
||||||
@@ -276,7 +288,7 @@ export class FrigateCardLive extends LitElement {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-live-carousel>
|
</frigate-card-live-grid>
|
||||||
`,
|
`,
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
@@ -284,16 +296,13 @@ export class FrigateCardLive extends LitElement {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(liveStyle);
|
return unsafeCSS(basicBlockStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-live-carousel')
|
@customElement('frigate-card-live-grid')
|
||||||
export class FrigateCardLiveCarousel extends LitElement {
|
export class FrigateCardLiveGrid extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
@@ -301,7 +310,10 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
public view?: Readonly<View>;
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public liveConfig?: LiveConfig;
|
public nonOverriddenLiveConfig?: LiveConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public overriddenLiveConfig?: LiveConfig;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public liveOverrides?: LiveOverrides;
|
public liveOverrides?: LiveOverrides;
|
||||||
@@ -321,6 +333,109 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneStream?: MediaStream;
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
|
protected _renderCarousel(cameraID?: string): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<frigate-card-live-carousel
|
||||||
|
grid-id=${ifDefined(cameraID)}
|
||||||
|
.hass=${this.hass}
|
||||||
|
.view=${this.view}
|
||||||
|
.viewFilterCameraID=${cameraID}
|
||||||
|
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||||
|
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||||
|
.inBackground=${this.inBackground}
|
||||||
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
|
.liveOverrides=${this.liveOverrides}
|
||||||
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.cameraManager=${this.cameraManager}
|
||||||
|
.microphoneStream=${this.microphoneStream}
|
||||||
|
>
|
||||||
|
</frigate-card-live-carousel>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _gridSelectCamera(cameraID: string, view?: View): void {
|
||||||
|
(view ?? this.view)
|
||||||
|
?.evolve({
|
||||||
|
camera: cameraID,
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _needsGrid(): boolean {
|
||||||
|
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||||
|
return !!this.view?.isGrid() && !!cameraIDs && cameraIDs.size >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
|
if (changedProps.has('view') && this._needsGrid()) {
|
||||||
|
import('../media-grid.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (!this.conditionControllerEpoch || !this.nonOverriddenLiveConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||||
|
if (!this._needsGrid() || !cameraIDs) {
|
||||||
|
return this._renderCarousel();
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<frigate-card-media-grid
|
||||||
|
.selected=${this.view?.camera}
|
||||||
|
.displayConfig=${this.overriddenLiveConfig?.display}
|
||||||
|
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
||||||
|
this._gridSelectCamera(ev.detail.selected)}
|
||||||
|
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this._gridSelectCamera(ev.detail.camera, ev.detail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
|
||||||
|
</frigate-card-media-grid>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(basicBlockStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-live-carousel')
|
||||||
|
export class FrigateCardLiveCarousel extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public nonOverriddenLiveConfig?: LiveConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public overriddenLiveConfig?: LiveConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public liveOverrides?: LiveOverrides;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public inBackground?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public viewFilterCameraID?: string;
|
||||||
|
|
||||||
// Index between camera name and slide number.
|
// Index between camera name and slide number.
|
||||||
protected _cameraToSlide: Record<string, number> = {};
|
protected _cameraToSlide: Record<string, number> = {};
|
||||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||||
@@ -357,7 +472,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected _getTransitionEffect(): TransitionEffect {
|
protected _getTransitionEffect(): TransitionEffect {
|
||||||
return (
|
return (
|
||||||
this.liveConfig?.transition_effect ??
|
this.overriddenLiveConfig?.transition_effect ??
|
||||||
frigateCardConfigDefaults.live.transition_effect
|
frigateCardConfigDefaults.live.transition_effect
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -376,7 +491,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected _getOptions(): EmblaOptionsType {
|
protected _getOptions(): EmblaOptionsType {
|
||||||
return {
|
return {
|
||||||
draggable: this.liveConfig?.draggable,
|
// If the carousel is being filtered to a single cameraID, it is never
|
||||||
|
// draggable.
|
||||||
|
draggable: !this.viewFilterCameraID && this.overriddenLiveConfig?.draggable,
|
||||||
loop: true,
|
loop: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -386,10 +503,12 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
* @returns A list of EmblaOptionsTypes.
|
* @returns A list of EmblaOptionsTypes.
|
||||||
*/
|
*/
|
||||||
protected _getPlugins(): EmblaCarouselPlugins {
|
protected _getPlugins(): EmblaCarouselPlugins {
|
||||||
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
|
const cameraCount = this.viewFilterCameraID
|
||||||
|
? 1
|
||||||
|
: this.cameraManager?.getStore().getVisibleCameraCount() ?? 0;
|
||||||
return [
|
return [
|
||||||
// Only enable wheel plugin if there is more than one camera.
|
// Only enable wheel plugin if there is more than one camera.
|
||||||
...(cameras && cameras.size > 1
|
...(cameraCount > 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
|
||||||
@@ -399,28 +518,28 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
Lazyload({
|
Lazyload({
|
||||||
...(this.liveConfig?.lazy_load && {
|
...(this.overriddenLiveConfig?.lazy_load && {
|
||||||
lazyLoadCallback: (index, slide) =>
|
lazyLoadCallback: (index, slide) =>
|
||||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lazyUnloadCondition: this.liveConfig?.lazy_unload,
|
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
|
||||||
lazyUnloadCallback: (index, slide) =>
|
lazyUnloadCallback: (index, slide) =>
|
||||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||||
}),
|
}),
|
||||||
AutoMediaPlugin({
|
AutoMediaPlugin({
|
||||||
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
||||||
...(this.liveConfig?.auto_play && {
|
...(this.overriddenLiveConfig?.auto_play && {
|
||||||
autoPlayCondition: this.liveConfig.auto_play,
|
autoPlayCondition: this.overriddenLiveConfig.auto_play,
|
||||||
}),
|
}),
|
||||||
...(this.liveConfig?.auto_pause && {
|
...(this.overriddenLiveConfig?.auto_pause && {
|
||||||
autoPauseCondition: this.liveConfig.auto_pause,
|
autoPauseCondition: this.overriddenLiveConfig.auto_pause,
|
||||||
}),
|
}),
|
||||||
...(this.liveConfig?.auto_mute && {
|
...(this.overriddenLiveConfig?.auto_mute && {
|
||||||
autoMuteCondition: this.liveConfig.auto_mute,
|
autoMuteCondition: this.overriddenLiveConfig.auto_mute,
|
||||||
}),
|
}),
|
||||||
...(this.liveConfig?.auto_unmute && {
|
...(this.overriddenLiveConfig?.auto_unmute && {
|
||||||
autoUnmuteCondition: this.liveConfig.auto_unmute,
|
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@@ -435,7 +554,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected _getLazyLoadCount(): number | null {
|
protected _getLazyLoadCount(): number | null {
|
||||||
// Defaults to fully-lazy loading.
|
// Defaults to fully-lazy loading.
|
||||||
return this.liveConfig?.lazy_load === false ? null : 0;
|
return this.overriddenLiveConfig?.lazy_load === false ? null : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,15 +563,25 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
* name to slide number.
|
* name to slide number.
|
||||||
*/
|
*/
|
||||||
protected _getSlides(): [TemplateResult[], Record<string, number>] {
|
protected _getSlides(): [TemplateResult[], Record<string, number>] {
|
||||||
const visibleCameras = this.cameraManager?.getStore().getVisibleCameras();
|
let cameras: CameraConfigs | null = null;
|
||||||
if (!visibleCameras) {
|
if (this.viewFilterCameraID) {
|
||||||
|
const config = this.cameraManager
|
||||||
|
?.getStore()
|
||||||
|
.getCameraConfig(this.viewFilterCameraID);
|
||||||
|
if (config) {
|
||||||
|
cameras = new Map([[this.viewFilterCameraID, config]]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cameras = this.cameraManager?.getStore().getVisibleCameras() ?? null;
|
||||||
|
}
|
||||||
|
if (!cameras) {
|
||||||
return [[], {}];
|
return [[], {}];
|
||||||
}
|
}
|
||||||
|
|
||||||
const slides: TemplateResult[] = [];
|
const slides: TemplateResult[] = [];
|
||||||
const cameraToSlide: Record<string, number> = {};
|
const cameraToSlide: Record<string, number> = {};
|
||||||
|
|
||||||
for (const [cameraID, cameraConfig] of visibleCameras) {
|
for (const [cameraID, cameraConfig] of cameras) {
|
||||||
const liveCameraID =
|
const liveCameraID =
|
||||||
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
||||||
const liveCameraConfig =
|
const liveCameraConfig =
|
||||||
@@ -525,7 +654,8 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
slideIndex: number,
|
slideIndex: number,
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
if (
|
if (
|
||||||
!this.liveConfig ||
|
!this.overriddenLiveConfig ||
|
||||||
|
!this.nonOverriddenLiveConfig ||
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.cameraManager ||
|
!this.cameraManager ||
|
||||||
!this.conditionControllerEpoch
|
!this.conditionControllerEpoch
|
||||||
@@ -538,7 +668,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
// stateOverride to evaluate the condition in that context.
|
// stateOverride to evaluate the condition in that context.
|
||||||
const config = getOverriddenConfig(
|
const config = getOverriddenConfig(
|
||||||
this.conditionControllerEpoch.controller,
|
this.conditionControllerEpoch.controller,
|
||||||
this.liveConfig,
|
this.nonOverriddenLiveConfig,
|
||||||
this.liveOverrides,
|
this.liveOverrides,
|
||||||
{ camera: cameraID },
|
{ camera: cameraID },
|
||||||
) as LiveConfig;
|
) as LiveConfig;
|
||||||
@@ -548,7 +678,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
<frigate-card-live-provider
|
<frigate-card-live-provider
|
||||||
?disabled=${this.liveConfig.lazy_load}
|
?disabled=${config.lazy_load}
|
||||||
.microphoneStream=${this.view?.camera === cameraID
|
.microphoneStream=${this.view?.camera === cameraID
|
||||||
? this.microphoneStream
|
? this.microphoneStream
|
||||||
: undefined}
|
: undefined}
|
||||||
@@ -575,11 +705,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
|
|
||||||
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
|
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
|
||||||
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||||
if (!cameras || !this.view || !this.hass) {
|
if (this.viewFilterCameraID || !cameras || !this.view || !this.hass) {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cameraID = this.viewFilterCameraID ?? this.view.camera;
|
||||||
const keys = Array.from(cameras.keys());
|
const keys = Array.from(cameras.keys());
|
||||||
const currentIndex = keys.indexOf(this.view.camera);
|
const currentIndex = keys.indexOf(cameraID);
|
||||||
|
|
||||||
if (currentIndex < 0 || cameras.size <= 1) {
|
if (currentIndex < 0 || cameras.size <= 1) {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
@@ -596,7 +728,7 @@ export class FrigateCardLiveCarousel 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 {
|
||||||
if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) {
|
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,12 +749,17 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
: null;
|
: null;
|
||||||
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
|
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
|
||||||
this.hass,
|
this.hass,
|
||||||
overrideCameraID(this.view.camera),
|
overrideCameraID(this.viewFilterCameraID ?? this.view.camera),
|
||||||
);
|
);
|
||||||
const cameraMetadataNext = nextID
|
const cameraMetadataNext = nextID
|
||||||
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID))
|
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const titleConfig = getDefaultTitleConfigForView(
|
||||||
|
this.view,
|
||||||
|
this.overriddenLiveConfig?.controls.title,
|
||||||
|
);
|
||||||
|
|
||||||
// Notes on the below:
|
// Notes on the below:
|
||||||
// - guard() is used to avoid reseting the carousel unless the
|
// - guard() is used to avoid reseting the carousel unless the
|
||||||
// options/plugins actually change.
|
// options/plugins actually change.
|
||||||
@@ -637,18 +774,18 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
<frigate-card-media-carousel
|
<frigate-card-media-carousel
|
||||||
${ref(this._refMediaCarousel)}
|
${ref(this._refMediaCarousel)}
|
||||||
.carouselOptions=${guard(
|
.carouselOptions=${guard(
|
||||||
[this.cameraManager, this.liveConfig],
|
[this.cameraManager, this.overriddenLiveConfig],
|
||||||
this._getOptions.bind(this),
|
this._getOptions.bind(this),
|
||||||
)}
|
)}
|
||||||
.carouselPlugins=${guard(
|
.carouselPlugins=${guard(
|
||||||
[this.cameraManager, this.liveConfig],
|
[this.cameraManager, this.overriddenLiveConfig],
|
||||||
this._getPlugins.bind(this),
|
this._getPlugins.bind(this),
|
||||||
) as EmblaCarouselPlugins}
|
) as EmblaCarouselPlugins}
|
||||||
.label="${cameraMetadataCurrent
|
.label="${cameraMetadataCurrent
|
||||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||||
: ''}"
|
: ''}"
|
||||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||||
.titlePopupConfig=${this.liveConfig.controls.title}
|
.titlePopupConfig=${titleConfig ?? undefined}
|
||||||
.selected=${this._getSelectedCameraIndex()}
|
.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)}
|
||||||
@@ -661,7 +798,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
slot="previous"
|
slot="previous"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'previous'}
|
.direction=${'previous'}
|
||||||
.controlConfig=${this.liveConfig.controls.next_previous}
|
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
|
||||||
.label=${cameraMetadataPrevious?.title ?? ''}
|
.label=${cameraMetadataPrevious?.title ?? ''}
|
||||||
.icon=${cameraMetadataPrevious?.icon}
|
.icon=${cameraMetadataPrevious?.icon}
|
||||||
?disabled=${prevID === null}
|
?disabled=${prevID === null}
|
||||||
@@ -676,7 +813,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
slot="next"
|
slot="next"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'next'}
|
.direction=${'next'}
|
||||||
.controlConfig=${this.liveConfig.controls.next_previous}
|
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
|
||||||
.label=${cameraMetadataNext?.title ?? ''}
|
.label=${cameraMetadataNext?.title ?? ''}
|
||||||
.icon=${cameraMetadataNext?.icon}
|
.icon=${cameraMetadataNext?.icon}
|
||||||
?disabled=${nextID === null}
|
?disabled=${nextID === null}
|
||||||
@@ -794,7 +931,7 @@ export class FrigateCardLiveProvider
|
|||||||
public async getScreenshotURL(): Promise<string | null> {
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this._refProvider.value?.updateComplete;
|
await this._refProvider.value?.updateComplete;
|
||||||
return await this._refProvider.value?.getScreenshotURL() ?? null;
|
return (await this._refProvider.value?.getScreenshotURL()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1002,6 +1139,7 @@ declare global {
|
|||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
|
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
|
||||||
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
||||||
|
'frigate-card-live-grid': FrigateCardLiveGrid;
|
||||||
'frigate-card-live': FrigateCardLive;
|
'frigate-card-live': FrigateCardLive;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,9 +428,6 @@ export class FrigateCardMediaCarousel extends LitElement {
|
|||||||
: ``}`;
|
: ``}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get element styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(mediaCarouselStyle);
|
return unsafeCSS(mediaCarouselStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// TODO: Performance of video scanning (pause/play?)
|
||||||
|
// TODO: Investigate query spam during a grid load
|
||||||
|
// TODO: Test live pre-load
|
||||||
|
// TODO: Is the query reset in card.ts correct for media filter multi-camera queries that are not all cameras?
|
||||||
|
// TODO: Do I need column max?
|
||||||
|
// TODO: test changing tabs in a dashboard (to trigger disconnect, do I still receive media loads from media that was already loaded)?
|
||||||
|
// TODO: Can SELECT_CHILD_EVENTS only be 'click' and it still work on Android?
|
||||||
|
|
||||||
|
import {
|
||||||
|
CSSResultGroup,
|
||||||
|
html,
|
||||||
|
LitElement,
|
||||||
|
PropertyValues,
|
||||||
|
TemplateResult,
|
||||||
|
unsafeCSS,
|
||||||
|
} from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
|
import mediaGridStyle from '../scss/media-grid.scss';
|
||||||
|
import { ViewDisplayConfig } from '../types.js';
|
||||||
|
import { MediaGridController } from '../utils/media-grid-controller.js';
|
||||||
|
|
||||||
|
@customElement('frigate-card-media-grid')
|
||||||
|
export class FrigateCardMediaGrid extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public selected?: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public displayConfig?: ViewDisplayConfig;
|
||||||
|
|
||||||
|
protected _controller: MediaGridController | null = null;
|
||||||
|
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
// Ensure the controller is recreated.
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this._controller?.destroy();
|
||||||
|
this._controller = null;
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updated(changedProps: PropertyValues): void {
|
||||||
|
if (!this._controller && this._refSlot.value) {
|
||||||
|
this._controller = new MediaGridController(this._refSlot.value, {
|
||||||
|
selected: this.selected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('selected')) {
|
||||||
|
if (this.selected) {
|
||||||
|
this._controller?.selectCell(this.selected);
|
||||||
|
} else {
|
||||||
|
this._controller?.unselectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('displayConfig')) {
|
||||||
|
this._controller?.setDisplayConfig(this.displayConfig ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html`<slot ${ref(this._refSlot)}></slot> `;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(mediaGridStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'frigate-card-media-grid': FrigateCardMediaGrid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ 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 { CameraManager } from '../camera-manager/manager.js';
|
||||||
|
import type { DataQuery } from '../camera-manager/types';
|
||||||
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
import {
|
import {
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
ClipsOrSnapshotsOrAll,
|
ClipsOrSnapshotsOrAll,
|
||||||
@@ -16,13 +18,11 @@ import {
|
|||||||
ThumbnailsControlConfig,
|
ThumbnailsControlConfig,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
|
||||||
import { View } from '../view/view.js';
|
|
||||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
|
||||||
import './surround-basic.js';
|
|
||||||
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
|
|
||||||
import { getAllDependentCameras } from '../utils/camera.js';
|
import { getAllDependentCameras } from '../utils/camera.js';
|
||||||
import type { DataQuery } from '../camera-manager/types';
|
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
|
||||||
|
import { View } from '../view/view.js';
|
||||||
|
import './surround-basic.js';
|
||||||
|
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||||
|
|
||||||
interface ThumbnailViewContext {
|
interface ThumbnailViewContext {
|
||||||
// Whether or not to fetch thumbnails.
|
// Whether or not to fetch thumbnails.
|
||||||
@@ -88,6 +88,7 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
this.cardWideConfig,
|
this.cardWideConfig,
|
||||||
this.view,
|
this.view,
|
||||||
{
|
{
|
||||||
|
allCameras: this.view.isGrid(),
|
||||||
targetView: this.view.view,
|
targetView: this.view.view,
|
||||||
mediaType: this.fetchMedia,
|
mediaType: this.fetchMedia,
|
||||||
select: 'latest',
|
select: 'latest',
|
||||||
@@ -151,10 +152,6 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.view) {
|
if (!this.hass || !this.view) {
|
||||||
return;
|
return;
|
||||||
@@ -230,11 +227,8 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
</frigate-card-surround-basic>`;
|
</frigate-card-surround-basic>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return compiled CSS styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(surroundStyle);
|
return unsafeCSS(basicBlockStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
this,
|
this,
|
||||||
'thumbnail-carousel:tap',
|
'thumbnail-carousel:tap',
|
||||||
{
|
{
|
||||||
queryResults: this.view.queryResults.clone().selectResult(index),
|
queryResults: this.view.queryResults.clone().selectIndex(index),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,7 +485,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
: results
|
: results
|
||||||
.clone()
|
.clone()
|
||||||
.resetSelectedResult()
|
.resetSelectedResult()
|
||||||
.selectBestResult((media) => findBestMediaIndex(media, targetTime));
|
.selectBestResult((media) => findBestMediaIndex(media, targetTime), {
|
||||||
|
allCameras: true,
|
||||||
|
main: true,
|
||||||
|
});
|
||||||
|
|
||||||
const desiredView: FrigateCardView = this.mini
|
const desiredView: FrigateCardView = this.mini
|
||||||
? targetTime >= new Date()
|
? targetTime >= new Date()
|
||||||
@@ -496,8 +499,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
this.view
|
this.view
|
||||||
.evolve({
|
.evolve({
|
||||||
view: desiredView,
|
view: desiredView,
|
||||||
...(newResults &&
|
queryResults: newResults,
|
||||||
newResults.hasSelectedResult() && { queryResults: newResults }),
|
|
||||||
}) // Whether or not to set the timeline window.
|
}) // Whether or not to set the timeline window.
|
||||||
.mergeInContext({
|
.mergeInContext({
|
||||||
...(canSeek && { mediaViewer: { seek: targetTime } }),
|
...(canSeek && { mediaViewer: { seek: targetTime } }),
|
||||||
@@ -597,10 +599,15 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (properties.item && properties.what === 'item') {
|
} else if (properties.item && properties.what === 'item') {
|
||||||
|
const cameraID = String(properties.group);
|
||||||
|
const criteria = {
|
||||||
|
main: true,
|
||||||
|
...(cameraID && this.view.isGrid() && { cameraID: cameraID }),
|
||||||
|
};
|
||||||
const newResults = this.view.queryResults
|
const newResults = this.view.queryResults
|
||||||
?.clone()
|
?.clone()
|
||||||
.resetSelectedResult()
|
.resetSelectedResult()
|
||||||
.selectResultIfFound((media) => media.getID() === properties.item);
|
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
|
||||||
|
|
||||||
if (!newResults || !newResults.hasSelectedResult()) {
|
if (!newResults || !newResults.hasSelectedResult()) {
|
||||||
// This can happen in a few situations:
|
// This can happen in a few situations:
|
||||||
@@ -788,11 +795,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
return new DataSet(groups);
|
return new DataSet(groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null {
|
protected _getPerfectWindowFromMediaStartAndEndTime(
|
||||||
const startTime = media.getStartTime();
|
isEvent: boolean,
|
||||||
const endTime = media.getEndTime();
|
startTime: Date | null,
|
||||||
|
endTime: Date | null,
|
||||||
if (ViewMediaClassifier.isEvent(media)) {
|
): TimelineWindow | null {
|
||||||
|
if (isEvent) {
|
||||||
const windowSeconds = this._getConfiguredWindowSeconds();
|
const windowSeconds = this._getConfiguredWindowSeconds();
|
||||||
|
|
||||||
if (startTime && endTime) {
|
if (startTime && endTime) {
|
||||||
@@ -820,7 +828,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
end: add(startTime, { seconds: windowSeconds / 2 }),
|
end: add(startTime, { seconds: windowSeconds / 2 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) {
|
} else if (startTime && endTime) {
|
||||||
return {
|
return {
|
||||||
start: startTime,
|
start: startTime,
|
||||||
end: endTime,
|
end: endTime,
|
||||||
@@ -980,8 +988,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
|
|
||||||
let desiredWindow = timelineWindow;
|
let desiredWindow = timelineWindow;
|
||||||
const media = this.view.queryResults?.getSelectedResult();
|
const media = this.view.queryResults?.getSelectedResult();
|
||||||
const mediaStartTime = media?.getStartTime();
|
const mediaStartTime = media?.getStartTime() ?? null;
|
||||||
const mediaEndTime = media?.getEndTime();
|
const mediaEndTime = media?.getEndTime() ?? null;
|
||||||
|
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
|
||||||
|
|
||||||
const mediaWindow: TimelineWindow | null =
|
const mediaWindow: TimelineWindow | null =
|
||||||
media && mediaStartTime
|
media && mediaStartTime
|
||||||
? // If this media has no end time, it's just a "point" in time so the
|
? // If this media has no end time, it's just a "point" in time so the
|
||||||
@@ -996,8 +1006,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
|
|
||||||
if (context && context.window) {
|
if (context && context.window) {
|
||||||
desiredWindow = context.window;
|
desiredWindow = context.window;
|
||||||
} else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
|
} else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
|
||||||
const perfectMediaWindow = this._getPerfectWindowFromMedia(media);
|
const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime(
|
||||||
|
mediaIsEvent,
|
||||||
|
mediaStartTime,
|
||||||
|
mediaEndTime,
|
||||||
|
);
|
||||||
if (perfectMediaWindow) {
|
if (perfectMediaWindow) {
|
||||||
desiredWindow = perfectMediaWindow;
|
desiredWindow = perfectMediaWindow;
|
||||||
}
|
}
|
||||||
@@ -1013,22 +1027,22 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
|
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentSelection = this._timeline.getSelection();
|
||||||
const mediaID = media?.getID();
|
const mediaID = media?.getID();
|
||||||
if (media && mediaID && this._isClustering()) {
|
const needToSelect = mediaID && mediaIsEvent && !currentSelection.includes(mediaID);
|
||||||
// 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. Only do this when the pointer is not held to avoid
|
|
||||||
// interrupting the user and to make the timeline smoother.
|
|
||||||
|
|
||||||
// Need to this rewrite prior to setting the selection (just below), or
|
if (needToSelect) {
|
||||||
// the selection will be lost on rewrite.
|
if (this._isClustering()) {
|
||||||
this._timelineSource?.rewriteEvent(mediaID);
|
// 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 desiredId =
|
// Need to this rewrite prior to setting the selection (just below), or
|
||||||
!!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null;
|
// the selection will be lost on rewrite.
|
||||||
if (desiredId) {
|
this._timelineSource?.rewriteEvent(mediaID);
|
||||||
this._timeline?.setSelection([desiredId], {
|
}
|
||||||
|
|
||||||
|
this._timeline?.setSelection([mediaID], {
|
||||||
focus: false,
|
focus: false,
|
||||||
animation: {
|
animation: {
|
||||||
animation: false,
|
animation: false,
|
||||||
@@ -1244,9 +1258,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return compiled CSS styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(timelineCoreStyle);
|
return unsafeCSS(timelineCoreStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
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 { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
|
import basicBlockStyle from '../scss/basic-block.scss';
|
||||||
|
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||||
import { View } from '../view/view';
|
import { View } from '../view/view';
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import './timeline-core.js';
|
import './timeline-core.js';
|
||||||
@@ -24,10 +24,6 @@ export class FrigateCardTimeline extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.timelineConfig) {
|
if (!this.timelineConfig) {
|
||||||
return html``;
|
return html``;
|
||||||
@@ -49,11 +45,8 @@ export class FrigateCardTimeline extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return compiled CSS styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(timelineStyle);
|
return unsafeCSS(basicBlockStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { TitleControlConfig } from '../types.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
|
||||||
import titleStyle from '../scss/title-control.scss';
|
import titleStyle from '../scss/title-control.scss';
|
||||||
|
import { TitleControlConfig } from '../types.js';
|
||||||
|
import { View } from '../view/view.js';
|
||||||
|
|
||||||
type PaperToast = HTMLElement & {
|
type PaperToast = HTMLElement & {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDefaultTitleConfigForView = (
|
||||||
|
view?: Readonly<View>,
|
||||||
|
baseConfig?: TitleControlConfig,
|
||||||
|
): TitleControlConfig | null => {
|
||||||
|
if (!baseConfig && view?.isGrid()) {
|
||||||
|
return { mode: 'none', duration_seconds: 2 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'popup-bottom-right',
|
||||||
|
duration_seconds: 2,
|
||||||
|
...baseConfig,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@customElement('frigate-card-title-control')
|
@customElement('frigate-card-title-control')
|
||||||
export class FrigateCardTitleControl extends LitElement {
|
export class FrigateCardTitleControl extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
|
|||||||
+257
-101
@@ -10,7 +10,9 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
@@ -29,9 +31,14 @@ import {
|
|||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
import { mayHaveAudio } from '../utils/audio.js';
|
import { mayHaveAudio } from '../utils/audio.js';
|
||||||
import { contentsChanged, errorToConsole } from '../utils/basic.js';
|
import {
|
||||||
|
contentsChanged,
|
||||||
|
errorToConsole,
|
||||||
|
setOrRemoveAttribute,
|
||||||
|
} from '../utils/basic.js';
|
||||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||||
|
import { MediaGridSelected } from '../utils/media-grid-controller.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
dispatchMediaLoadedEvent,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
@@ -65,6 +72,7 @@ import {
|
|||||||
import './next-prev-control.js';
|
import './next-prev-control.js';
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import './title-control.js';
|
import './title-control.js';
|
||||||
|
import { getDefaultTitleConfigForView } from './title-control.js';
|
||||||
|
|
||||||
export interface MediaViewerViewContext {
|
export interface MediaViewerViewContext {
|
||||||
seek?: Date;
|
seek?: Date;
|
||||||
@@ -76,6 +84,16 @@ declare module 'view' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaNeighbor {
|
||||||
|
index: number;
|
||||||
|
media: ViewMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaNeighbors {
|
||||||
|
previous?: MediaNeighbor;
|
||||||
|
next?: MediaNeighbor;
|
||||||
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-viewer')
|
@customElement('frigate-card-viewer')
|
||||||
export class FrigateCardViewer extends LitElement {
|
export class FrigateCardViewer extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
@@ -129,6 +147,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
this.cardWideConfig,
|
this.cardWideConfig,
|
||||||
this.view,
|
this.view,
|
||||||
{
|
{
|
||||||
|
allCameras: this.view.isGrid(),
|
||||||
targetView: 'recording',
|
targetView: 'recording',
|
||||||
select: 'latest',
|
select: 'latest',
|
||||||
},
|
},
|
||||||
@@ -141,6 +160,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
this.cardWideConfig,
|
this.cardWideConfig,
|
||||||
this.view,
|
this.view,
|
||||||
{
|
{
|
||||||
|
allCameras: this.view.isGrid(),
|
||||||
targetView: 'media',
|
targetView: 'media',
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
select: 'latest',
|
select: 'latest',
|
||||||
@@ -150,17 +170,15 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html` <frigate-card-viewer-grid
|
||||||
<frigate-card-viewer-carousel
|
.hass=${this.hass}
|
||||||
.hass=${this.hass}
|
.view=${this.view}
|
||||||
.view=${this.view}
|
.viewerConfig=${this.viewerConfig}
|
||||||
.viewerConfig=${this.viewerConfig}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.cameraManager=${this.cameraManager}
|
||||||
.cameraManager=${this.cameraManager}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
>
|
||||||
>
|
</frigate-card-viewer-grid>`;
|
||||||
</frigate-card-viewer-carousel>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,6 +199,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public viewFilterCameraID?: string;
|
||||||
|
|
||||||
// Resetting the viewer configuration causes a full reset so ensure the config
|
// Resetting the viewer configuration causes a full reset so ensure the config
|
||||||
// has actually changed with a full comparison (dynamic configuration
|
// has actually changed with a full comparison (dynamic configuration
|
||||||
// overrides may causes changes elsewhere in the full card configuration that
|
// overrides may causes changes elsewhere in the full card configuration that
|
||||||
@@ -198,7 +219,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public selected = 0;
|
||||||
|
|
||||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||||
|
protected _media: ViewMedia[] | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The updated lifecycle callback for this element.
|
* The updated lifecycle callback for this element.
|
||||||
@@ -255,7 +280,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?.queryResults?.getResultsCount() ?? 0 > 1
|
...(this._media && this._media.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
|
||||||
@@ -292,20 +317,28 @@ 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(): [ViewMedia | null, ViewMedia | null] {
|
protected _getMediaNeighbors(): MediaNeighbors | null {
|
||||||
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
const mediaCount = this._media?.length ?? 0;
|
||||||
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
if (!this._media || this.selected === null) {
|
||||||
if (!this.view || !this.view.queryResults || selectedIndex === null) {
|
return null;
|
||||||
return [null, null];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous: ViewMedia | null =
|
const prevIndex = this.selected > 0 ? this.selected - 1 : null;
|
||||||
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null;
|
const nextIndex = this.selected + 1 < mediaCount ? this.selected + 1 : null;
|
||||||
const next: ViewMedia | null =
|
return {
|
||||||
selectedIndex + 1 < resultCount
|
...(prevIndex !== null && {
|
||||||
? this.view.queryResults.getResult(selectedIndex + 1)
|
previous: {
|
||||||
: null;
|
index: prevIndex,
|
||||||
return [previous, next];
|
media: this._media[prevIndex],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(nextIndex !== null && {
|
||||||
|
next: {
|
||||||
|
index: nextIndex,
|
||||||
|
media: this._media[nextIndex],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||||
@@ -313,23 +346,26 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected _setViewSelectedIndex(index: number): void {
|
protected _setViewSelectedIndex(index: number): void {
|
||||||
if (!this.view?.queryResults) {
|
if (!this._media) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIndex = this.view.queryResults.getSelectedIndex();
|
if (this.selected === null || this.selected === index) {
|
||||||
if (selectedIndex === null || selectedIndex === index) {
|
|
||||||
// The slide may already be selected on load, so don't dispatch a new view
|
// The slide may already be selected on load, so don't dispatch a new view
|
||||||
// unless necessary (i.e. the new index is different from the current
|
// unless necessary (i.e. the new index is different from the current
|
||||||
// index).
|
// index).
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newResults = this.view?.queryResults?.clone().selectResult(index);
|
const newResults = this.view?.queryResults
|
||||||
|
?.clone()
|
||||||
|
.selectIndex(index, this.viewFilterCameraID);
|
||||||
if (!newResults) {
|
if (!newResults) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cameraID = newResults.getSelectedResult()?.getCameraID();
|
const cameraID = newResults
|
||||||
|
.getSelectedResult(this.viewFilterCameraID)
|
||||||
|
?.getCameraID();
|
||||||
|
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
@@ -338,6 +374,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
// Always change the camera to the owner of the selected media.
|
// Always change the camera to the owner of the selected media.
|
||||||
...(cameraID && { camera: cameraID }),
|
...(cameraID && { camera: cameraID }),
|
||||||
})
|
})
|
||||||
|
.removeContextProperty('mediaViewer', 'seek')
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,13 +400,13 @@ 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 (!this.view || !this.view.queryResults) {
|
if (!this._media) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const slides: TemplateResult[] = [];
|
const slides: TemplateResult[] = [];
|
||||||
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
for (let i = 0; i < this._media.length; ++i) {
|
||||||
const media = this.view.queryResults.getResult(i);
|
const media = this._media[i];
|
||||||
if (media) {
|
if (media) {
|
||||||
const slide = this._renderMediaItem(media, i);
|
const slide = this._renderMediaItem(media, i);
|
||||||
if (slide) {
|
if (slide) {
|
||||||
@@ -388,11 +425,25 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
if (changedProps.has('viewerConfig')) {
|
if (changedProps.has('viewerConfig')) {
|
||||||
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
|
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('view')) {
|
||||||
|
const newMedia =
|
||||||
|
this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
|
||||||
|
const newSelected =
|
||||||
|
this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
|
||||||
|
const newSeek = this.view?.context?.mediaViewer?.seek;
|
||||||
|
|
||||||
|
if (newMedia !== this._media || newSelected !== this.selected || !newSeek) {
|
||||||
|
setOrRemoveAttribute(this, false, 'unseekable');
|
||||||
|
this._media = newMedia;
|
||||||
|
this.selected = newSelected;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
const mediaCount = this._media?.length ?? 0;
|
||||||
if (!resultCount) {
|
if (!this._media || !mediaCount) {
|
||||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
||||||
icon: 'mdi:multimedia',
|
icon: 'mdi:multimedia',
|
||||||
});
|
});
|
||||||
@@ -401,82 +452,86 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
// If there's no selected media, just choose the last (most recent one) to
|
// If there's no selected media, just choose the last (most recent one) to
|
||||||
// avoid rendering a blank. This situation should not occur in practice, as
|
// avoid rendering a blank. This situation should not occur in practice, as
|
||||||
// this view should not be called without a selected media.
|
// this view should not be called without a selected media.
|
||||||
const media =
|
const selectedMedia = this._media[this.selected] ?? this._media[mediaCount - 1];
|
||||||
this.view?.queryResults?.getSelectedResult() ??
|
|
||||||
this.view?.queryResults?.getResult(resultCount - 1);
|
if (!this.hass || !this.cameraManager || !selectedMedia) {
|
||||||
if (
|
|
||||||
!this.hass ||
|
|
||||||
!this.cameraManager ||
|
|
||||||
!media ||
|
|
||||||
!this.view ||
|
|
||||||
!this.view.queryResults
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [prev, next] = this._getMediaNeighbors();
|
const neighbors = this._getMediaNeighbors();
|
||||||
|
|
||||||
const scroll = (direction: 'previous' | 'next'): void => {
|
const scroll = (direction: 'previous' | 'next'): void => {
|
||||||
const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
if (!neighbors || !this._media) {
|
||||||
if (!this.view || !this.view?.queryResults || currentIndex === null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
|
const newIndex =
|
||||||
if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) {
|
(direction === 'previous' ? neighbors.previous?.index : neighbors.next?.index) ??
|
||||||
|
null;
|
||||||
|
if (newIndex !== null) {
|
||||||
this._setViewSelectedIndex(newIndex);
|
this._setViewSelectedIndex(newIndex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
||||||
this.hass,
|
this.hass,
|
||||||
media.getCameraID(),
|
selectedMedia.getCameraID(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return html` <frigate-card-media-carousel
|
const titleConfig = getDefaultTitleConfigForView(
|
||||||
${ref(this._refMediaCarousel)}
|
this.view,
|
||||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
this.viewerConfig?.controls.title,
|
||||||
draggable: this.viewerConfig?.draggable ?? true,
|
);
|
||||||
}))}
|
|
||||||
.carouselPlugins=${guard(
|
return html`
|
||||||
[this.viewerConfig, this.view.queryResults.getResults()],
|
<frigate-card-media-carousel
|
||||||
this._getPlugins.bind(this),
|
${ref(this._refMediaCarousel)}
|
||||||
)}
|
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||||
.label=${media.getTitle() ?? undefined}
|
draggable: this.viewerConfig?.draggable ?? true,
|
||||||
.logo=${cameraMetadata?.engineLogo}
|
}))}
|
||||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
.carouselPlugins=${guard(
|
||||||
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
[this.viewerConfig, this._media],
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
this._getPlugins.bind(this),
|
||||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
)}
|
||||||
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
.label=${selectedMedia.getTitle() ?? undefined}
|
||||||
>
|
.logo=${cameraMetadata?.engineLogo}
|
||||||
<frigate-card-next-previous-control
|
.titlePopupConfig=${titleConfig ?? undefined}
|
||||||
slot="previous"
|
.selected=${this.selected ?? 0}
|
||||||
.hass=${this.hass}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
.direction=${'previous'}
|
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
||||||
.thumbnail=${prev?.getThumbnail() ?? undefined}
|
>
|
||||||
.label=${prev?.getTitle() ?? ''}
|
<frigate-card-next-previous-control
|
||||||
?disabled=${!prev}
|
slot="previous"
|
||||||
@click=${(ev) => {
|
.hass=${this.hass}
|
||||||
scroll('previous');
|
.direction=${'previous'}
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||||
}}
|
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
|
||||||
></frigate-card-next-previous-control>
|
.label=${neighbors?.previous?.media.getTitle() ?? ''}
|
||||||
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
|
?disabled=${!neighbors?.previous}
|
||||||
<frigate-card-next-previous-control
|
@click=${(ev: Event) => {
|
||||||
slot="next"
|
scroll('previous');
|
||||||
.hass=${this.hass}
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
.direction=${'next'}
|
}}
|
||||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
></frigate-card-next-previous-control>
|
||||||
.thumbnail=${next?.getThumbnail() ?? undefined}
|
${guard(this._media, () => this._getSlides())}
|
||||||
.label=${next?.getTitle() ?? ''}
|
<frigate-card-next-previous-control
|
||||||
?disabled=${!next}
|
slot="next"
|
||||||
@click=${(ev) => {
|
.hass=${this.hass}
|
||||||
scroll('next');
|
.direction=${'next'}
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||||
}}
|
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
|
||||||
></frigate-card-next-previous-control>
|
.label=${neighbors?.next?.media.getTitle() ?? ''}
|
||||||
</frigate-card-media-carousel>`;
|
?disabled=${!neighbors?.next}
|
||||||
|
@click=${(ev: Event) => {
|
||||||
|
scroll('next');
|
||||||
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
|
}}
|
||||||
|
></frigate-card-next-previous-control>
|
||||||
|
</frigate-card-media-carousel>
|
||||||
|
<div class="seek-warning">
|
||||||
|
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
|
||||||
|
</ha-icon>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -484,13 +539,26 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected async _seekHandler(): Promise<void> {
|
protected async _seekHandler(): Promise<void> {
|
||||||
const seek = this.view?.context?.mediaViewer?.seek;
|
const seek = this.view?.context?.mediaViewer?.seek;
|
||||||
const media = this.view?.queryResults?.getSelectedResult();
|
if (!this.hass || !seek || !this._media || this.selected === null) {
|
||||||
if (!this.hass || !media || !seek) {
|
return;
|
||||||
|
}
|
||||||
|
const selectedMedia = this._media[this.selected];
|
||||||
|
if (!selectedMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
||||||
|
|
||||||
|
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
|
||||||
|
if (!seekTimeInMedia) {
|
||||||
|
this._getPlayer()?.pause();
|
||||||
|
} else {
|
||||||
|
this._getPlayer()?.play();
|
||||||
|
}
|
||||||
|
|
||||||
const seekTime =
|
const seekTime =
|
||||||
(await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null;
|
(await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ??
|
||||||
|
null;
|
||||||
const player = this._getPlayer();
|
const player = this._getPlayer();
|
||||||
if (player && seekTime !== null) {
|
if (player && seekTime !== null) {
|
||||||
player.seek(seekTime);
|
player.seek(seekTime);
|
||||||
@@ -530,6 +598,93 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-viewer-grid')
|
||||||
|
export class FrigateCardViewerGrid extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public view?: Readonly<View>;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public viewerConfig?: ViewerConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public resolvedMediaCache?: ResolvedMediaCache;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<frigate-card-viewer-carousel
|
||||||
|
grid-id=${ifDefined(filterCamera)}
|
||||||
|
.hass=${this.hass}
|
||||||
|
.view=${this.view}
|
||||||
|
.viewFilterCameraID=${filterCamera}
|
||||||
|
.viewerConfig=${this.viewerConfig}
|
||||||
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
|
.cameraManager=${this.cameraManager}
|
||||||
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
>
|
||||||
|
</frigate-card-viewer-carousel>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _gridSelectCamera(cameraID: string, view?: View): void {
|
||||||
|
const newView = view ?? this.view;
|
||||||
|
const promotedQueryResults = newView?.queryResults
|
||||||
|
?.clone()
|
||||||
|
.promoteCameraSelectionToMainSelection(cameraID);
|
||||||
|
newView
|
||||||
|
?.evolve({
|
||||||
|
camera: cameraID,
|
||||||
|
queryResults: promotedQueryResults,
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
|
if (
|
||||||
|
changedProps.has('view') &&
|
||||||
|
this.view?.isGrid() &&
|
||||||
|
this.view?.hasMultipleDisplayModes()
|
||||||
|
) {
|
||||||
|
import('./media-grid.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
const cameraIDs = this.view?.queryResults?.getCameraIDs();
|
||||||
|
if (!cameraIDs || !this.view?.isGrid() || !this.view?.hasMultipleDisplayModes()) {
|
||||||
|
return this._renderCarousel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<frigate-card-media-grid
|
||||||
|
.selected=${this.view?.camera}
|
||||||
|
.displayConfig=${this.viewerConfig?.display}
|
||||||
|
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
||||||
|
this._gridSelectCamera(ev.detail.selected)}
|
||||||
|
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const childView = ev.detail;
|
||||||
|
this._gridSelectCamera(childView.camera, childView);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
|
||||||
|
</frigate-card-media-grid>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(basicBlockStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@customElement(FRIGATE_CARD_VIEWER_PROVIDER)
|
@customElement(FRIGATE_CARD_VIEWER_PROVIDER)
|
||||||
export class FrigateCardViewerProvider
|
export class FrigateCardViewerProvider
|
||||||
extends LitElement
|
extends LitElement
|
||||||
@@ -679,7 +834,7 @@ export class FrigateCardViewerProvider
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = new MediaQueriesResults(mediaArray);
|
const results = new MediaQueriesResults({ results: mediaArray });
|
||||||
results.selectResultIfFound(
|
results.selectResultIfFound(
|
||||||
(clipMedia) => clipMedia.getID() === this.media?.getID(),
|
(clipMedia) => clipMedia.getID() === this.media?.getID(),
|
||||||
);
|
);
|
||||||
@@ -830,6 +985,7 @@ declare global {
|
|||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
|
'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
|
||||||
'frigate-card-viewer': FrigateCardViewer;
|
'frigate-card-viewer': FrigateCardViewer;
|
||||||
|
'frigate-card-viewer-grid': FrigateCardViewerGrid;
|
||||||
FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider;
|
FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,8 @@ export class FrigateCardViews extends LitElement {
|
|||||||
<frigate-card-live
|
<frigate-card-live
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.liveConfig=${this.nonOverriddenConfig.live}
|
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
|
||||||
|
.overriddenLiveConfig=${this.config.live}
|
||||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
|
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
|
|||||||
+9
-3
@@ -7,6 +7,7 @@ import {
|
|||||||
frigateConditionalSchema,
|
frigateConditionalSchema,
|
||||||
OverrideConfigurationKey,
|
OverrideConfigurationKey,
|
||||||
RawFrigateCardConfig,
|
RawFrigateCardConfig,
|
||||||
|
ViewDisplayMode
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
interface ConditionState {
|
interface ConditionState {
|
||||||
@@ -16,6 +17,7 @@ interface ConditionState {
|
|||||||
camera?: string;
|
camera?: string;
|
||||||
state?: HassEntities;
|
state?: HassEntities;
|
||||||
media_loaded?: boolean;
|
media_loaded?: boolean;
|
||||||
|
displayMode?: ViewDisplayMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConditionEvaluateRequestEvent extends Event {
|
export class ConditionEvaluateRequestEvent extends Event {
|
||||||
@@ -176,10 +178,10 @@ export class ConditionController {
|
|||||||
}
|
}
|
||||||
if (condition.fullscreen !== undefined) {
|
if (condition.fullscreen !== undefined) {
|
||||||
result &&=
|
result &&=
|
||||||
state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
|
state.fullscreen !== undefined && condition.fullscreen === state.fullscreen;
|
||||||
}
|
}
|
||||||
if (condition.expand !== undefined) {
|
if (condition.expand !== undefined) {
|
||||||
result &&= state.expand !== undefined && condition.expand == state.expand;
|
result &&= state.expand !== undefined && condition.expand === state.expand;
|
||||||
}
|
}
|
||||||
if (condition.camera?.length) {
|
if (condition.camera?.length) {
|
||||||
result &&= !!state.camera && condition.camera.includes(state.camera);
|
result &&= !!state.camera && condition.camera.includes(state.camera);
|
||||||
@@ -198,11 +200,15 @@ export class ConditionController {
|
|||||||
}
|
}
|
||||||
if (condition.media_loaded !== undefined) {
|
if (condition.media_loaded !== undefined) {
|
||||||
result &&=
|
result &&=
|
||||||
state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
|
state.media_loaded !== undefined &&
|
||||||
|
condition.media_loaded === state.media_loaded;
|
||||||
}
|
}
|
||||||
if (condition.media_query) {
|
if (condition.media_query) {
|
||||||
result &&= window.matchMedia(condition.media_query).matches;
|
result &&= window.matchMedia(condition.media_query).matches;
|
||||||
}
|
}
|
||||||
|
if (condition.display_mode) {
|
||||||
|
result &&= !!state.displayMode && condition.display_mode === state.displayMode;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -98,6 +98,14 @@ export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as c
|
|||||||
export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
|
export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
|
||||||
export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
|
export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
|
||||||
export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
|
export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_DISPLAY_MODE =
|
||||||
|
`${CONF_MEDIA_VIEWER}.display.mode` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS =
|
||||||
|
`${CONF_MEDIA_VIEWER}.display.grid_columns` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS =
|
||||||
|
`${CONF_MEDIA_VIEWER}.display.grid_max_columns` as const;
|
||||||
|
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR =
|
||||||
|
`${CONF_MEDIA_VIEWER}.display.grid_selected_width_factor` as const;
|
||||||
export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
|
export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
|
||||||
export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
|
export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
|
||||||
export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
|
export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
|
||||||
@@ -185,6 +193,13 @@ export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
|
|||||||
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
||||||
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
||||||
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
||||||
|
export const CONF_LIVE_DISPLAY_MODE = `${CONF_LIVE}.display.mode` as const;
|
||||||
|
export const CONF_LIVE_DISPLAY_GRID_COLUMNS =
|
||||||
|
`${CONF_LIVE}.display.grid_columns` as const;
|
||||||
|
export const CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS =
|
||||||
|
`${CONF_LIVE}.display.grid_max_columns` as const;
|
||||||
|
export const CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR =
|
||||||
|
`${CONF_LIVE}.display.grid_selected_width_factor` as const;
|
||||||
export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const;
|
export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const;
|
||||||
export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
|
export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
|
||||||
export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
|
export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
|
||||||
@@ -272,7 +287,7 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
|
|||||||
// The number of media items to fetch at a time (for clips/snapshot views, and
|
// The number of media items to fetch at a time (for clips/snapshot views, and
|
||||||
// gallery chunks). Smaller values will cause more frequent smaller fetches, but
|
// gallery chunks). Smaller values will cause more frequent smaller fetches, but
|
||||||
// improved rendering performance.
|
// improved rendering performance.
|
||||||
export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
|
export const MEDIA_CHUNK_SIZE_DEFAULT = 500;
|
||||||
export const MEDIA_CHUNK_SIZE_MAX = 1000;
|
export const MEDIA_CHUNK_SIZE_MAX = 1000;
|
||||||
|
|
||||||
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
|
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
|
||||||
+76
-3
@@ -78,6 +78,10 @@ import {
|
|||||||
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||||
|
CONF_LIVE_DISPLAY_GRID_COLUMNS,
|
||||||
|
CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS,
|
||||||
|
CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
|
||||||
|
CONF_LIVE_DISPLAY_MODE,
|
||||||
CONF_LIVE_DRAGGABLE,
|
CONF_LIVE_DRAGGABLE,
|
||||||
CONF_LIVE_LAYOUT_FIT,
|
CONF_LIVE_LAYOUT_FIT,
|
||||||
CONF_LIVE_LAYOUT_POSITION_X,
|
CONF_LIVE_LAYOUT_POSITION_X,
|
||||||
@@ -117,6 +121,10 @@ import {
|
|||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
|
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_MODE,
|
||||||
CONF_MEDIA_VIEWER_DRAGGABLE,
|
CONF_MEDIA_VIEWER_DRAGGABLE,
|
||||||
CONF_MEDIA_VIEWER_LAYOUT_FIT,
|
CONF_MEDIA_VIEWER_LAYOUT_FIT,
|
||||||
CONF_MEDIA_VIEWER_LAYOUT_POSITION_X,
|
CONF_MEDIA_VIEWER_LAYOUT_POSITION_X,
|
||||||
@@ -126,8 +134,8 @@ import {
|
|||||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||||
CONF_MEDIA_VIEWER_ZOOMABLE,
|
CONF_MEDIA_VIEWER_ZOOMABLE,
|
||||||
CONF_MENU_ALIGNMENT,
|
CONF_MENU_ALIGNMENT,
|
||||||
CONF_MENU_BUTTON_SIZE,
|
|
||||||
CONF_MENU_BUTTONS,
|
CONF_MENU_BUTTONS,
|
||||||
|
CONF_MENU_BUTTON_SIZE,
|
||||||
CONF_MENU_POSITION,
|
CONF_MENU_POSITION,
|
||||||
CONF_MENU_STYLE,
|
CONF_MENU_STYLE,
|
||||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||||
@@ -165,9 +173,9 @@ import { setLowPerformanceProfile } from './performance.js';
|
|||||||
import frigate_card_editor_style from './scss/editor.scss';
|
import frigate_card_editor_style from './scss/editor.scss';
|
||||||
import {
|
import {
|
||||||
BUTTON_SIZE_MIN,
|
BUTTON_SIZE_MIN,
|
||||||
FRIGATE_MENU_PRIORITY_MAX,
|
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
frigateCardConfigDefaults,
|
frigateCardConfigDefaults,
|
||||||
|
FRIGATE_MENU_PRIORITY_MAX,
|
||||||
RawFrigateCardConfig,
|
RawFrigateCardConfig,
|
||||||
RawFrigateCardConfigArray,
|
RawFrigateCardConfigArray,
|
||||||
THUMBNAIL_WIDTH_MAX,
|
THUMBNAIL_WIDTH_MAX,
|
||||||
@@ -198,6 +206,7 @@ const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
|
|||||||
const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
|
const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
|
||||||
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
||||||
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
||||||
|
const MENU_LIVE_DISPLAY = 'live.display';
|
||||||
const MENU_LIVE_LAYOUT = 'live.layout';
|
const MENU_LIVE_LAYOUT = 'live.layout';
|
||||||
const MENU_LIVE_MICROPHONE = 'live.microphone';
|
const MENU_LIVE_MICROPHONE = 'live.microphone';
|
||||||
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
|
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
|
||||||
@@ -207,6 +216,7 @@ const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_pre
|
|||||||
const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails';
|
const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
|
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
|
const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
|
||||||
|
const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display';
|
||||||
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
|
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
|
||||||
const MENU_OPTIONS = 'options';
|
const MENU_OPTIONS = 'options';
|
||||||
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
||||||
@@ -545,6 +555,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
{ value: 'toggle', label: localize('config.menu.buttons.types.toggle') },
|
{ value: 'toggle', label: localize('config.menu.buttons.types.toggle') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected _displayModes: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{ value: 'single', label: localize('config.common.display.modes.single') },
|
||||||
|
{ value: 'grid', label: localize('config.common.display.modes.grid') },
|
||||||
|
];
|
||||||
|
|
||||||
public setConfig(config: RawFrigateCardConfig): void {
|
public setConfig(config: RawFrigateCardConfig): void {
|
||||||
// Note: This does not use Zod to parse the configuration, so it may be
|
// Note: This does not use Zod to parse the configuration, so it may be
|
||||||
// partially or completely invalid. It's more useful to have a partially
|
// partially or completely invalid. It's more useful to have a partially
|
||||||
@@ -1071,6 +1087,49 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the next & previous controls.
|
||||||
|
* @param domain The submenu domain.
|
||||||
|
* @param configPathStyle Next previous style config path.
|
||||||
|
* @param configPathSize Next previous size config path.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected _renderViewDisplay(
|
||||||
|
domain: string,
|
||||||
|
configPathMode: string,
|
||||||
|
configPathSelectedWidthFactor: string,
|
||||||
|
configPathColumns: string,
|
||||||
|
configPathMaxColumns: string,
|
||||||
|
): TemplateResult | void {
|
||||||
|
// grid_select_width_factor: z.number().min(0).optional(),
|
||||||
|
// grid_max_columns: z.number().min(0).optional(),
|
||||||
|
// grid_columns: z.number().min(0).optional(),
|
||||||
|
|
||||||
|
return this._putInSubmenu(
|
||||||
|
domain,
|
||||||
|
true,
|
||||||
|
'config.common.display.editor_label',
|
||||||
|
{ name: 'mdi:palette-swatch' },
|
||||||
|
html`
|
||||||
|
${this._renderOptionSelector(configPathMode, this._displayModes, {
|
||||||
|
label: localize('config.common.display.mode'),
|
||||||
|
})}
|
||||||
|
${this._renderNumberInput(configPathSelectedWidthFactor, {
|
||||||
|
min: 0,
|
||||||
|
label: localize('config.common.display.grid_selected_width_factor'),
|
||||||
|
})}
|
||||||
|
${this._renderNumberInput(configPathColumns, {
|
||||||
|
min: 0,
|
||||||
|
label: localize('config.common.display.grid_columns'),
|
||||||
|
})}
|
||||||
|
${this._renderNumberInput(configPathMaxColumns, {
|
||||||
|
min: 0,
|
||||||
|
label: localize('config.common.display.grid_max_columns'),
|
||||||
|
})}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the next & previous controls.
|
* Render the next & previous controls.
|
||||||
* @param domain The submenu domain.
|
* @param domain The submenu domain.
|
||||||
@@ -1785,7 +1844,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
)}`,
|
)}`,
|
||||||
)}
|
)}
|
||||||
${this._renderMenuButton('play') /* */}
|
${this._renderMenuButton('play') /* */}
|
||||||
${this._renderMenuButton('mute')}
|
${this._renderMenuButton('mute') /* */}
|
||||||
${this._renderMenuButton('screenshot')}
|
${this._renderMenuButton('screenshot')}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@@ -1826,6 +1885,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
||||||
this._defaults.live.show_image_during_load,
|
this._defaults.live.show_image_during_load,
|
||||||
)}
|
)}
|
||||||
|
${this._renderViewDisplay(
|
||||||
|
MENU_LIVE_DISPLAY,
|
||||||
|
CONF_LIVE_DISPLAY_MODE,
|
||||||
|
CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
|
||||||
|
CONF_LIVE_DISPLAY_GRID_COLUMNS,
|
||||||
|
CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS,
|
||||||
|
)}
|
||||||
${this._putInSubmenu(
|
${this._putInSubmenu(
|
||||||
MENU_LIVE_CONTROLS,
|
MENU_LIVE_CONTROLS,
|
||||||
true,
|
true,
|
||||||
@@ -1957,6 +2023,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
|
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
|
||||||
this._defaults.media_viewer.snapshot_click_plays_clip,
|
this._defaults.media_viewer.snapshot_click_plays_clip,
|
||||||
)}
|
)}
|
||||||
|
${this._renderViewDisplay(
|
||||||
|
MENU_MEDIA_VIEWER_DISPLAY,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_MODE,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS,
|
||||||
|
CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS,
|
||||||
|
)}
|
||||||
${this._putInSubmenu(
|
${this._putInSubmenu(
|
||||||
MENU_MEDIA_VIEWER_CONTROLS,
|
MENU_MEDIA_VIEWER_CONTROLS,
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -148,6 +148,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"display": {
|
||||||
|
"editor_label": "Display",
|
||||||
|
"grid_columns": "Exact number of grid columns",
|
||||||
|
"grid_max_columns": "Maximum number of grid columns",
|
||||||
|
"grid_selected_width_factor": "Increase selected media width by this factor",
|
||||||
|
"mode": "Mode",
|
||||||
|
"modes": {
|
||||||
|
"single": "Show single media viewer",
|
||||||
|
"grid": "Show media viewer for each camera in a grid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"fit": "Layout fit",
|
"fit": "Layout fit",
|
||||||
"fits": {
|
"fits": {
|
||||||
@@ -269,6 +280,7 @@
|
|||||||
"cameras": "Cameras",
|
"cameras": "Cameras",
|
||||||
"clips": "Clips",
|
"clips": "Clips",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"display_mode": "Display mode",
|
||||||
"enabled": "Button enabled",
|
"enabled": "Button enabled",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"frigate": "Frigate menu / Default view",
|
"frigate": "Frigate menu / Default view",
|
||||||
@@ -454,6 +466,9 @@
|
|||||||
"what": "What",
|
"what": "What",
|
||||||
"where": "Where"
|
"where": "Where"
|
||||||
},
|
},
|
||||||
|
"media_viewer": {
|
||||||
|
"unseekable": "Seek time not found in media"
|
||||||
|
},
|
||||||
"media_filter": {
|
"media_filter": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
|
|||||||
@@ -148,6 +148,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"display": {
|
||||||
|
"editor_label": "",
|
||||||
|
"grid_columns": "",
|
||||||
|
"grid_max_columns": "",
|
||||||
|
"grid_selected_width_factor": "",
|
||||||
|
"modes": {
|
||||||
|
"single": "",
|
||||||
|
"grid": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"fit": "Adatta al layout",
|
"fit": "Adatta al layout",
|
||||||
"fits": {
|
"fits": {
|
||||||
@@ -267,6 +277,7 @@
|
|||||||
"camera_ui": "Interfaccia utente della fotocamera",
|
"camera_ui": "Interfaccia utente della fotocamera",
|
||||||
"cameras": "Telecamere",
|
"cameras": "Telecamere",
|
||||||
"clips": "Clip",
|
"clips": "Clip",
|
||||||
|
"display_mode": "",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"enabled": "Pulsante abilitato",
|
"enabled": "Pulsante abilitato",
|
||||||
"expand": "Espandere",
|
"expand": "Espandere",
|
||||||
@@ -473,6 +484,9 @@
|
|||||||
},
|
},
|
||||||
"where": "Dove"
|
"where": "Dove"
|
||||||
},
|
},
|
||||||
|
"media_viewer": {
|
||||||
|
"unseekable": ""
|
||||||
|
},
|
||||||
"recording": {
|
"recording": {
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"duration": "Durata",
|
"duration": "Durata",
|
||||||
|
|||||||
@@ -148,6 +148,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"display": {
|
||||||
|
"editor_label": "",
|
||||||
|
"grid_columns": "",
|
||||||
|
"grid_max_columns": "",
|
||||||
|
"grid_selected_width_factor": "",
|
||||||
|
"mode": "",
|
||||||
|
"modes": {
|
||||||
|
"single": "",
|
||||||
|
"grid": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"fit": "Ajuste de layout",
|
"fit": "Ajuste de layout",
|
||||||
"fits": {
|
"fits": {
|
||||||
@@ -268,6 +279,7 @@
|
|||||||
"camera_ui": "Interface de usuário da câmera",
|
"camera_ui": "Interface de usuário da câmera",
|
||||||
"cameras": "Selecionar câmera",
|
"cameras": "Selecionar câmera",
|
||||||
"clips": "Clipes",
|
"clips": "Clipes",
|
||||||
|
"display_mode": "",
|
||||||
"download": "Baixe a mídia do evento",
|
"download": "Baixe a mídia do evento",
|
||||||
"enabled": "Botão ativado",
|
"enabled": "Botão ativado",
|
||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
@@ -482,6 +494,9 @@
|
|||||||
},
|
},
|
||||||
"where": "Onde"
|
"where": "Onde"
|
||||||
},
|
},
|
||||||
|
"media_viewer": {
|
||||||
|
"unseekable": ""
|
||||||
|
},
|
||||||
"recording": {
|
"recording": {
|
||||||
"camera": "Câmera",
|
"camera": "Câmera",
|
||||||
"duration": "Duração",
|
"duration": "Duração",
|
||||||
|
|||||||
@@ -148,6 +148,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"display": {
|
||||||
|
"editor_label": "",
|
||||||
|
"grid_columns": "",
|
||||||
|
"grid_max_columns": "",
|
||||||
|
"grid_selected_width_factor": "",
|
||||||
|
"mode": "",
|
||||||
|
"modes": {
|
||||||
|
"single": "",
|
||||||
|
"grid": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"fit": "Fit",
|
"fit": "Fit",
|
||||||
"fits": {
|
"fits": {
|
||||||
@@ -260,6 +271,7 @@
|
|||||||
"camera_ui": "Camera",
|
"camera_ui": "Camera",
|
||||||
"cameras": "Selecionar câmera",
|
"cameras": "Selecionar câmera",
|
||||||
"clips": "Clipes",
|
"clips": "Clipes",
|
||||||
|
"display_mode": "",
|
||||||
"download": "Descarregar mídia do evento",
|
"download": "Descarregar mídia do evento",
|
||||||
"enabled": "Botão ativado",
|
"enabled": "Botão ativado",
|
||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
@@ -465,6 +477,9 @@
|
|||||||
},
|
},
|
||||||
"where": "Onde"
|
"where": "Onde"
|
||||||
},
|
},
|
||||||
|
"media_viewer": {
|
||||||
|
"unseekable": ""
|
||||||
|
},
|
||||||
"recording": {
|
"recording": {
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"duration": "Duração",
|
"duration": "Duração",
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// If the carousel has an unselected attribute set on it, do not let the
|
||||||
|
// pointer interact (e.g. hover, scroll) with underlying elements. This is used
|
||||||
|
// when the carousel is part of a media-grid. Without this next/prev controls
|
||||||
|
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
|
||||||
|
// See matching in viewer-carousel.scss .
|
||||||
|
:host([unselected]) frigate-card-media-carousel {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.embla__slide {
|
.embla__slide {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
flex: 0 0 100%;
|
flex: 0 0 100%;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
:host {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
--frigate-card-grid-border-size: 3px;
|
||||||
|
--frigate-card-grid-column-size: 100%;
|
||||||
|
--frigate-card-grid-selected-width-factor: 2;
|
||||||
|
|
||||||
|
// Allow the grid to scroll if necessary (e.g. fullscreen).
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
// Hide scrollbar: Firefox
|
||||||
|
scrollbar-width: none;
|
||||||
|
// Hide scrollbar: IE and Edge
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
:host::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted(*) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: var(--ha-card-border-radius, 4px);
|
||||||
|
overflow: hidden;
|
||||||
|
width: var(--frigate-card-grid-column-size);
|
||||||
|
|
||||||
|
// Unselected items included a transparent border to act as the effective
|
||||||
|
// gutter between elements, and to ensure when the item is selected it does
|
||||||
|
// not change in size (even border-box sizing appears to allow size to change
|
||||||
|
// when the element has a non-fixed height).
|
||||||
|
border: var(--frigate-card-grid-border-size) solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted([selected]) {
|
||||||
|
border: var(--frigate-card-grid-border-size) solid var(--primary-color);
|
||||||
|
width: min(
|
||||||
|
100%,
|
||||||
|
calc(
|
||||||
|
var(--frigate-card-grid-selected-width-factor) *
|
||||||
|
var(--frigate-card-grid-column-size)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
slot {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
:host {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
:host {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
:host {
|
:host {
|
||||||
--paper-toast-background-color: rgba(0,0,0,0.6);
|
--paper-toast-background-color: rgba(0,0,0,0.6);
|
||||||
--paper-toast-color: white;
|
--paper-toast-color: white;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
paper-toast {
|
paper-toast {
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
|
:host {
|
||||||
|
// Center unseekable icon.
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the carousel has an unselected attribute set on it, do not let the
|
||||||
|
// pointer interact (e.g. hover, scroll) with underlying elements. This is used
|
||||||
|
// when the carousel is part of a media-grid. Without this next/prev controls
|
||||||
|
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
|
||||||
|
// See matching in live-carousel.scss .
|
||||||
|
:host([unselected]) frigate-card-media-carousel,
|
||||||
|
:host([unselected]) .seek-warning
|
||||||
|
{
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([unseekable]) frigate-card-media-carousel {
|
||||||
|
filter: brightness(50%);
|
||||||
|
}
|
||||||
|
:host([unseekable]) .seek-warning {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-warning {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-50%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.embla__slide {
|
.embla__slide {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
flex: 0 0 100%;
|
flex: 0 0 100%;
|
||||||
|
|||||||
+28
-15
@@ -105,6 +105,17 @@ export class FrigateCardError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewDisplayModeSchema = z.enum(['single', 'grid']);
|
||||||
|
export type ViewDisplayMode = z.infer<typeof viewDisplayModeSchema>;
|
||||||
|
|
||||||
|
const viewDisplaySchema = z.object({
|
||||||
|
mode: viewDisplayModeSchema,
|
||||||
|
grid_selected_width_factor: z.number().min(0).optional(),
|
||||||
|
grid_max_columns: z.number().min(0).optional(),
|
||||||
|
grid_columns: z.number().min(0).optional(),
|
||||||
|
}).optional();
|
||||||
|
export type ViewDisplayConfig = z.infer<typeof viewDisplaySchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action Types (for "Picture Elements" / Menu)
|
* Action Types (for "Picture Elements" / Menu)
|
||||||
*/
|
*/
|
||||||
@@ -231,6 +242,7 @@ const FRIGATE_CARD_ACTIONS = [
|
|||||||
'camera_select',
|
'camera_select',
|
||||||
'live_substream_select',
|
'live_substream_select',
|
||||||
'media_player',
|
'media_player',
|
||||||
|
'display_mode_select',
|
||||||
] as const;
|
] as const;
|
||||||
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
|
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
|
||||||
|
|
||||||
@@ -256,6 +268,12 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex
|
|||||||
media_player: z.string(),
|
media_player: z.string(),
|
||||||
media_player_action: z.enum(['play', 'stop']),
|
media_player_action: z.enum(['play', 'stop']),
|
||||||
});
|
});
|
||||||
|
const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchema.extend(
|
||||||
|
{
|
||||||
|
frigate_card_action: z.literal('display_mode_select'),
|
||||||
|
display_mode: viewDisplayModeSchema,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const frigateCardCustomActionSchema = z.union([
|
export const frigateCardCustomActionSchema = z.union([
|
||||||
frigateCardViewActionSchema,
|
frigateCardViewActionSchema,
|
||||||
@@ -263,6 +281,7 @@ export const frigateCardCustomActionSchema = z.union([
|
|||||||
frigateCardCameraSelectActionSchema,
|
frigateCardCameraSelectActionSchema,
|
||||||
frigateCardLiveDependencySelectActionSchema,
|
frigateCardLiveDependencySelectActionSchema,
|
||||||
frigateCardMediaPlayerActionSchema,
|
frigateCardMediaPlayerActionSchema,
|
||||||
|
frigateCardViewDisplayModeActionSchema,
|
||||||
]);
|
]);
|
||||||
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
||||||
|
|
||||||
@@ -647,6 +666,7 @@ export const frigateCardConditionSchema = z.object({
|
|||||||
media_loaded: z.boolean().optional(),
|
media_loaded: z.boolean().optional(),
|
||||||
state: stateConditions.optional(),
|
state: stateConditions.optional(),
|
||||||
media_query: z.string().optional(),
|
media_query: z.string().optional(),
|
||||||
|
display_mode: viewDisplayModeSchema.optional(),
|
||||||
});
|
});
|
||||||
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
|
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
|
||||||
|
|
||||||
@@ -946,6 +966,7 @@ const liveConfigDefault = {
|
|||||||
zoomable: true,
|
zoomable: true,
|
||||||
transition_effect: 'slide' as const,
|
transition_effect: 'slide' as const,
|
||||||
show_image_during_load: true,
|
show_image_during_load: true,
|
||||||
|
mode: 'single' as const,
|
||||||
controls: {
|
controls: {
|
||||||
builtin: true,
|
builtin: true,
|
||||||
next_previous: {
|
next_previous: {
|
||||||
@@ -954,10 +975,6 @@ const liveConfigDefault = {
|
|||||||
},
|
},
|
||||||
thumbnails: liveThumbnailControlsDefaults,
|
thumbnails: liveThumbnailControlsDefaults,
|
||||||
timeline: miniTimelineConfigDefault,
|
timeline: miniTimelineConfigDefault,
|
||||||
title: {
|
|
||||||
mode: 'popup-bottom-right' as const,
|
|
||||||
duration_seconds: 2,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
microphone: {
|
microphone: {
|
||||||
...microphoneConfigDefault,
|
...microphoneConfigDefault,
|
||||||
@@ -990,16 +1007,7 @@ const liveOverridableConfigSchema = z
|
|||||||
liveConfigDefault.controls.thumbnails,
|
liveConfigDefault.controls.thumbnails,
|
||||||
),
|
),
|
||||||
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
||||||
title: titleControlConfigSchema
|
title: titleControlConfigSchema.optional(),
|
||||||
.extend({
|
|
||||||
mode: titleControlConfigSchema.shape.mode.default(
|
|
||||||
liveConfigDefault.controls.title.mode,
|
|
||||||
),
|
|
||||||
duration_seconds: titleControlConfigSchema.shape.duration_seconds.default(
|
|
||||||
liveConfigDefault.controls.title.duration_seconds,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.default(liveConfigDefault.controls.title),
|
|
||||||
})
|
})
|
||||||
.default(liveConfigDefault.controls),
|
.default(liveConfigDefault.controls),
|
||||||
show_image_during_load: z
|
show_image_during_load: z
|
||||||
@@ -1008,6 +1016,7 @@ const liveOverridableConfigSchema = z
|
|||||||
layout: mediaLayoutConfigSchema.optional(),
|
layout: mediaLayoutConfigSchema.optional(),
|
||||||
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
|
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
|
||||||
zoomable: z.boolean().default(liveConfigDefault.zoomable),
|
zoomable: z.boolean().default(liveConfigDefault.zoomable),
|
||||||
|
display: viewDisplaySchema,
|
||||||
})
|
})
|
||||||
.merge(actionsSchema);
|
.merge(actionsSchema);
|
||||||
|
|
||||||
@@ -1078,6 +1087,7 @@ const menuConfigDefault = {
|
|||||||
play: hiddenButtonDefault,
|
play: hiddenButtonDefault,
|
||||||
recordings: hiddenButtonDefault,
|
recordings: hiddenButtonDefault,
|
||||||
screenshot: hiddenButtonDefault,
|
screenshot: hiddenButtonDefault,
|
||||||
|
display_mode: visibleButtonDefault,
|
||||||
},
|
},
|
||||||
button_size: 40,
|
button_size: 40,
|
||||||
};
|
};
|
||||||
@@ -1124,6 +1134,7 @@ const menuConfigSchema = z
|
|||||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||||
|
display_mode: visibleButtonSchema.default(menuConfigDefault.buttons.display_mode),
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons),
|
.default(menuConfigDefault.buttons),
|
||||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||||
@@ -1144,6 +1155,7 @@ const viewerConfigDefault = {
|
|||||||
zoomable: true,
|
zoomable: true,
|
||||||
transition_effect: 'slide' as const,
|
transition_effect: 'slide' as const,
|
||||||
snapshot_click_plays_clip: true,
|
snapshot_click_plays_clip: true,
|
||||||
|
display_mode: 'single' as const,
|
||||||
controls: {
|
controls: {
|
||||||
builtin: true,
|
builtin: true,
|
||||||
next_previous: {
|
next_previous: {
|
||||||
@@ -1190,6 +1202,7 @@ const viewerConfigSchema = z
|
|||||||
snapshot_click_plays_clip: z
|
snapshot_click_plays_clip: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.default(viewerConfigDefault.snapshot_click_plays_clip),
|
.default(viewerConfigDefault.snapshot_click_plays_clip),
|
||||||
|
display: viewDisplaySchema,
|
||||||
controls: z
|
controls: z
|
||||||
.object({
|
.object({
|
||||||
builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
|
builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
|
||||||
@@ -1373,7 +1386,7 @@ const performanceConfigDefault = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const performanceConfigSchema = z
|
export const performanceConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile),
|
profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile),
|
||||||
features: z
|
features: z
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
frigateCardCustomActionSchema,
|
frigateCardCustomActionSchema,
|
||||||
FrigateCardViewAction,
|
FrigateCardViewAction,
|
||||||
|
ViewDisplayMode,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +43,7 @@ export function createFrigateCardCustomAction(
|
|||||||
camera?: string;
|
camera?: string;
|
||||||
media_player?: string;
|
media_player?: string;
|
||||||
media_player_action?: 'play' | 'stop';
|
media_player_action?: 'play' | 'stop';
|
||||||
|
display_mode?: ViewDisplayMode;
|
||||||
},
|
},
|
||||||
): FrigateCardCustomAction | null {
|
): FrigateCardCustomAction | null {
|
||||||
if (action === 'camera_select' || action === 'live_substream_select') {
|
if (action === 'camera_select' || action === 'live_substream_select') {
|
||||||
@@ -67,6 +69,17 @@ export function createFrigateCardCustomAction(
|
|||||||
...(args.cardID && { card_id: args.cardID }),
|
...(args.cardID && { card_id: args.cardID }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (action === 'display_mode_select') {
|
||||||
|
if (!args?.display_mode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: action,
|
||||||
|
display_mode: args?.display_mode,
|
||||||
|
...(args.cardID && { card_id: args.cardID }),
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
action: 'fire-dom-event',
|
action: 'fire-dom-event',
|
||||||
frigate_card_action: action,
|
frigate_card_action: action,
|
||||||
|
|||||||
@@ -221,3 +221,8 @@ export const setOrRemoveAttribute = (
|
|||||||
element.removeAttribute(name);
|
element.removeAttribute(name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow typescript to narrow types based on truthy filter.
|
||||||
|
*/
|
||||||
|
export const filterTruthy = <T>(x: T | false | undefined | null | '' | 0): x is T => !!x;
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import throttle from 'lodash-es/throttle';
|
||||||
|
import Masonry from 'masonry-layout';
|
||||||
|
import { MediaLoadedInfo, ViewDisplayConfig } from '../types';
|
||||||
|
import { dispatchFrigateCardEvent, setOrRemoveAttribute } from './basic';
|
||||||
|
import {
|
||||||
|
FrigateMediaLoadedEventTarget,
|
||||||
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
|
dispatchMediaUnloadedEvent,
|
||||||
|
} from './media-info';
|
||||||
|
|
||||||
|
// The default minimum cell width: if the columns are not specified this value
|
||||||
|
// is used to compute the number of columns, always trying to keep each cell as
|
||||||
|
// at least this width. On Android, a card in portrait mode is 396 pixels, and
|
||||||
|
// we'd like to support two cells wide in that configuration.
|
||||||
|
const MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH = 190;
|
||||||
|
const MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH = 600;
|
||||||
|
const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0;
|
||||||
|
|
||||||
|
type GridID = string;
|
||||||
|
type MediaGridChild = HTMLElement & FrigateMediaLoadedEventTarget;
|
||||||
|
type MediaGridContents = Map<GridID, MediaGridChild>;
|
||||||
|
|
||||||
|
export interface MediaGridSelected {
|
||||||
|
selected: GridID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaGridConstructorOptions {
|
||||||
|
selected?: GridID;
|
||||||
|
idAttribute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_CHILD_EVENTS = ['click', 'touchend'];
|
||||||
|
|
||||||
|
export class MediaGridController {
|
||||||
|
protected _host: HTMLElement;
|
||||||
|
|
||||||
|
protected _selected: GridID | null;
|
||||||
|
protected _mediaLoadedInfoMap: Map<GridID, MediaLoadedInfo> = new Map();
|
||||||
|
protected _gridContents: MediaGridContents = new Map();
|
||||||
|
protected _masonry: Masonry | null = null;
|
||||||
|
protected _displayConfig: ViewDisplayConfig | null = null;
|
||||||
|
protected _hostWidth: number;
|
||||||
|
protected _idAttribute: string;
|
||||||
|
|
||||||
|
protected _throttledLayout = throttle(
|
||||||
|
() => this._masonry?.layout?.(),
|
||||||
|
// Throttle layout calls to larger than the masonry.js transitionDuration
|
||||||
|
// value specified below.
|
||||||
|
500,
|
||||||
|
{ trailing: true, leading: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
protected _mutationObserver = new MutationObserver(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
(_mutations: MutationRecord[], _observer: MutationObserver) =>
|
||||||
|
this._calculateGridContentsFromHost(),
|
||||||
|
);
|
||||||
|
protected _cellResizeObserver = new ResizeObserver(this._cellResizeHandler.bind(this));
|
||||||
|
protected _hostResizeObserver = new ResizeObserver(this._hostResizeHandler.bind(this));
|
||||||
|
|
||||||
|
constructor(host: HTMLElement, options?: MediaGridConstructorOptions) {
|
||||||
|
this._host = host;
|
||||||
|
this._selected = options?.selected ?? null;
|
||||||
|
this._idAttribute = options?.idAttribute ?? 'grid-id';
|
||||||
|
this._hostWidth = this._host.getBoundingClientRect().width;
|
||||||
|
this._hostResizeObserver.observe(host);
|
||||||
|
|
||||||
|
this._calculateGridContentsFromHost();
|
||||||
|
this._mutationObserver.observe(host, { childList: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this._hostResizeObserver.disconnect();
|
||||||
|
this._cellResizeObserver.disconnect();
|
||||||
|
this._mediaLoadedInfoMap.clear();
|
||||||
|
this._masonry?.destroy?.();
|
||||||
|
this._masonry = null;
|
||||||
|
|
||||||
|
for (const child of this._gridContents.values()) {
|
||||||
|
this._removeChildEventListeners(child);
|
||||||
|
}
|
||||||
|
this._gridContents.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setDisplayConfig(displayConfig: ViewDisplayConfig | null): void {
|
||||||
|
this._displayConfig = displayConfig;
|
||||||
|
this._calculateGridContentsFromHost();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGridContents(): MediaGridContents {
|
||||||
|
return this._gridContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGridSize(): number {
|
||||||
|
return this._gridContents.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSelected(): GridID | null {
|
||||||
|
return this._selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public selectCell(id: GridID) {
|
||||||
|
if (this._selected === id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._selected = id;
|
||||||
|
dispatchFrigateCardEvent(this._host, 'media-grid:selected', { selected: id });
|
||||||
|
|
||||||
|
const mediaLoadedInfo = this._mediaLoadedInfoMap.get(id);
|
||||||
|
if (mediaLoadedInfo) {
|
||||||
|
dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateSelectedStylesOnElements();
|
||||||
|
|
||||||
|
// Sizes may change when an element is selected, so re-do the layout (must
|
||||||
|
// come after the call to _updateStylesOnElements in order to ensure the
|
||||||
|
// right styles are applied first).
|
||||||
|
this._throttledLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
public unselectAll() {
|
||||||
|
if (this._selected !== null) {
|
||||||
|
dispatchMediaUnloadedEvent(this._host);
|
||||||
|
dispatchFrigateCardEvent(this._host, 'media-grid:unselected');
|
||||||
|
}
|
||||||
|
this._selected = null;
|
||||||
|
this._updateSelectedStylesOnElements();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _calculateGridContentsFromHost(): void {
|
||||||
|
let childrenElements: Element[];
|
||||||
|
|
||||||
|
if (this._host instanceof HTMLSlotElement) {
|
||||||
|
childrenElements = this._host.assignedElements({ flatten: true });
|
||||||
|
} else {
|
||||||
|
childrenElements = [...this._host.children];
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridContents: MediaGridContents = new Map();
|
||||||
|
for (const child of childrenElements) {
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
|
||||||
|
gridContents.set(id, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setGridContents(gridContents);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setGridContents(elements: MediaGridContents): void {
|
||||||
|
this._gridContents = elements;
|
||||||
|
|
||||||
|
// Remove media loaded info objects that belong to objects no longer in the
|
||||||
|
// grid.
|
||||||
|
for (const key of this._mediaLoadedInfoMap.keys()) {
|
||||||
|
if (!elements.has(key)) {
|
||||||
|
this._mediaLoadedInfoMap.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._selected !== null && !this._gridContents.has(this._selected)) {
|
||||||
|
this.unselectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of elements.values()) {
|
||||||
|
this._removeChildEventListeners(element);
|
||||||
|
this._addChildEventListeners(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setColumnSizeStyles();
|
||||||
|
this._createMasonry();
|
||||||
|
|
||||||
|
// Observe grid elements for size changes.
|
||||||
|
this._cellResizeObserver.disconnect();
|
||||||
|
for (const child of elements.values()) {
|
||||||
|
this._cellResizeObserver.observe(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateSelectedStylesOnElements();
|
||||||
|
this._setColumnSizeStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _handleMediaLoadedInfoEvent = (ev: CustomEvent<MediaLoadedInfo>): void => {
|
||||||
|
const eventPath = ev.composedPath();
|
||||||
|
|
||||||
|
for (const [id, element] of this._gridContents.entries()) {
|
||||||
|
if (eventPath.includes(element)) {
|
||||||
|
this._mediaLoadedInfoMap.set(id, ev.detail);
|
||||||
|
if (id !== this._selected) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _hostResizeHandler(): void {
|
||||||
|
const dimensions = this._host.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Only resize things if the width has changed. It is expected that the
|
||||||
|
// height may change during the layout.
|
||||||
|
if (dimensions.width !== this._hostWidth) {
|
||||||
|
this._hostWidth = dimensions.width;
|
||||||
|
|
||||||
|
// Reset the column CSS sizes first.
|
||||||
|
this._setColumnSizeStyles();
|
||||||
|
|
||||||
|
// Need to recreate the masonry layout since the column width will differ.
|
||||||
|
this._createMasonry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _cellResizeHandler(): void {
|
||||||
|
this._throttledLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _removeChildEventListeners(child: MediaGridChild): void {
|
||||||
|
for (const event of SELECT_CHILD_EVENTS) {
|
||||||
|
child.removeEventListener(event, this._handleSelectGridCellEvent, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
child.removeEventListener(
|
||||||
|
'frigate-card:media:loaded',
|
||||||
|
this._handleMediaLoadedInfoEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _addChildEventListeners(child: MediaGridChild): void {
|
||||||
|
for (const event of SELECT_CHILD_EVENTS) {
|
||||||
|
child.addEventListener(event, this._handleSelectGridCellEvent, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
child.addEventListener(
|
||||||
|
'frigate-card:media:loaded',
|
||||||
|
this._handleMediaLoadedInfoEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _createMasonry(): void {
|
||||||
|
if (this._masonry) {
|
||||||
|
this._masonry.destroy?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._masonry = new Masonry(this._host, {
|
||||||
|
columnWidth: this._getColumnSize(),
|
||||||
|
initLayout: false,
|
||||||
|
percentPosition: true,
|
||||||
|
transitionDuration: '0.3s',
|
||||||
|
});
|
||||||
|
this._masonry.addItems?.([...this._gridContents.values()]);
|
||||||
|
this._throttledLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _handleSelectGridCellEvent = (ev: Event): void => {
|
||||||
|
const eventPath = ev.composedPath();
|
||||||
|
|
||||||
|
for (const [id, element] of this._gridContents.entries()) {
|
||||||
|
if (eventPath.includes(element)) {
|
||||||
|
if (this._selected !== id) {
|
||||||
|
this.selectCell(id);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _updateSelectedStylesOnElements(): void {
|
||||||
|
for (const [id, element] of this._gridContents.entries()) {
|
||||||
|
setOrRemoveAttribute(element, id === this._selected, 'selected');
|
||||||
|
|
||||||
|
// Explicitly use an 'unselected' attribute vs a :not(selected) such that
|
||||||
|
// a carousel with neither selected nor unselected will behave normally.
|
||||||
|
// This matches a css selector in viewer-carousel.scss .
|
||||||
|
setOrRemoveAttribute(element, id !== this._selected, 'unselected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getColumnSize(): number {
|
||||||
|
return Math.round(this._hostWidth / this._getColumns());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getColumns(): number {
|
||||||
|
if (this._displayConfig?.grid_columns) {
|
||||||
|
return this._displayConfig?.grid_columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = this._displayConfig?.grid_max_columns ?? Infinity;
|
||||||
|
|
||||||
|
// See if we can get a multi-column layout using the ideal cell width.
|
||||||
|
const idealColumns = Math.min(
|
||||||
|
maxColumns,
|
||||||
|
Math.floor(this._hostWidth / MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH),
|
||||||
|
);
|
||||||
|
if (idealColumns > 1) {
|
||||||
|
return idealColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not, get a multi-column view using the minimum cell width.
|
||||||
|
const minColumns = Math.floor(
|
||||||
|
Math.min(maxColumns, this._hostWidth / MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Last result use at least 1 column.
|
||||||
|
return Math.max(1, minColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setColumnSizeStyles(): void {
|
||||||
|
this._host.style.setProperty(
|
||||||
|
'--frigate-card-grid-column-size',
|
||||||
|
`${this._getColumnSize()}px`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._host.style.setProperty(
|
||||||
|
'--frigate-card-grid-selected-width-factor',
|
||||||
|
`${
|
||||||
|
this._displayConfig?.grid_selected_width_factor ??
|
||||||
|
MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,3 +111,33 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
|
|||||||
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
|
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Facilities correct Typescript typing of media:loaded event handlers.
|
||||||
|
export interface FrigateMediaLoadedEventTarget extends EventTarget {
|
||||||
|
addEventListener(
|
||||||
|
event: 'frigate-card:media:loaded',
|
||||||
|
listener: (
|
||||||
|
this: FrigateMediaLoadedEventTarget,
|
||||||
|
ev: CustomEvent<MediaLoadedInfo>,
|
||||||
|
) => void,
|
||||||
|
options?: AddEventListenerOptions | boolean,
|
||||||
|
): void;
|
||||||
|
addEventListener(
|
||||||
|
type: string,
|
||||||
|
callback: EventListenerOrEventListenerObject,
|
||||||
|
options?: AddEventListenerOptions | boolean,
|
||||||
|
): void;
|
||||||
|
removeEventListener(
|
||||||
|
event: 'frigate-card:media:loaded',
|
||||||
|
listener: (
|
||||||
|
this: FrigateMediaLoadedEventTarget,
|
||||||
|
ev: CustomEvent<MediaLoadedInfo>,
|
||||||
|
) => void,
|
||||||
|
options?: boolean | EventListenerOptions,
|
||||||
|
): void;
|
||||||
|
removeEventListener(
|
||||||
|
type: string,
|
||||||
|
callback: EventListenerOrEventListenerObject,
|
||||||
|
options?: boolean | EventListenerOptions,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|||||||
+28
-29
@@ -1,20 +1,20 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
|
import { MediaQuery } from '../camera-manager/types';
|
||||||
|
import { dispatchFrigateCardErrorEvent } from '../components/message';
|
||||||
|
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
|
||||||
import { CardWideConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
import { CardWideConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||||
import { View } from '../view/view';
|
import { ViewMedia } from '../view/media';
|
||||||
import {
|
import {
|
||||||
EventMediaQueries,
|
EventMediaQueries,
|
||||||
MediaQueries,
|
MediaQueries,
|
||||||
RecordingMediaQueries,
|
RecordingMediaQueries,
|
||||||
} from '../view/media-queries';
|
} from '../view/media-queries';
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
|
||||||
import { getAllDependentCameras } from './camera.js';
|
|
||||||
import { ViewMedia } from '../view/media';
|
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
|
||||||
import { dispatchFrigateCardErrorEvent } from '../components/message';
|
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||||
|
import { View } from '../view/view';
|
||||||
import { errorToConsole } from './basic';
|
import { errorToConsole } from './basic';
|
||||||
import { MediaQuery } from '../camera-manager/types';
|
import { getAllDependentCameras } from './camera.js';
|
||||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
|
|
||||||
|
|
||||||
type ResultSelectType = 'latest' | 'time' | 'none';
|
type ResultSelectType = 'latest' | 'time' | 'none';
|
||||||
|
|
||||||
@@ -25,13 +25,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
|||||||
cardWideConfig: CardWideConfig,
|
cardWideConfig: CardWideConfig,
|
||||||
view: View,
|
view: View,
|
||||||
options?: {
|
options?: {
|
||||||
|
allCameras?: boolean;
|
||||||
mediaType?: ClipsOrSnapshotsOrAll;
|
mediaType?: ClipsOrSnapshotsOrAll;
|
||||||
targetView?: FrigateCardView;
|
targetView?: FrigateCardView;
|
||||||
select?: ResultSelectType;
|
select?: ResultSelectType;
|
||||||
},
|
},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
|
const cameraIDs = options?.allCameras
|
||||||
if (!cameraIDs) {
|
? cameraManager.getStore().getVisibleCameraIDs()
|
||||||
|
: getAllDependentCameras(cameraManager, view.camera);
|
||||||
|
if (!cameraIDs.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +87,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
|||||||
cardWideConfig: CardWideConfig,
|
cardWideConfig: CardWideConfig,
|
||||||
view: View,
|
view: View,
|
||||||
options?: {
|
options?: {
|
||||||
|
allCameras?: boolean;
|
||||||
targetView?: 'recording' | 'recordings';
|
targetView?: 'recording' | 'recordings';
|
||||||
select?: ResultSelectType;
|
select?: ResultSelectType;
|
||||||
},
|
},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
|
const cameraIDs = options?.allCameras
|
||||||
if (!cameraIDs) {
|
? cameraManager.getStore().getVisibleCameraIDs()
|
||||||
|
: getAllDependentCameras(cameraManager, view.camera);
|
||||||
|
if (!cameraIDs.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +167,7 @@ export const executeMediaQueryForView = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryResults = new MediaQueriesResults(
|
const queryResults = new MediaQueriesResults({ results: mediaArray });
|
||||||
mediaArray,
|
|
||||||
options?.select === 'latest' && mediaArray.length
|
|
||||||
? mediaArray.length - 1
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
let viewerContext: ViewContext | undefined = {};
|
let viewerContext: ViewContext | undefined = {};
|
||||||
|
|
||||||
if (options?.select === 'time' && options?.targetTime) {
|
if (options?.select === 'time' && options?.targetTime) {
|
||||||
@@ -180,16 +181,14 @@ export const executeMediaQueryForView = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return view
|
||||||
view
|
.evolve({
|
||||||
?.evolve({
|
query: query,
|
||||||
query: query,
|
queryResults: queryResults,
|
||||||
queryResults: queryResults,
|
view: options?.targetView,
|
||||||
view: options?.targetView,
|
camera: options?.targetCameraID,
|
||||||
camera: options?.targetCameraID,
|
})
|
||||||
})
|
.mergeInContext(viewerContext);
|
||||||
.mergeInContext(viewerContext) ?? null
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +200,7 @@ export const executeMediaQueryForView = async (
|
|||||||
*/
|
*/
|
||||||
export const findBestMediaIndex = (
|
export const findBestMediaIndex = (
|
||||||
mediaArray: ViewMedia[],
|
mediaArray: ViewMedia[],
|
||||||
targetTime: Date
|
targetTime: Date,
|
||||||
): number | null => {
|
): number | null => {
|
||||||
let bestMatch:
|
let bestMatch:
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { CameraManager } from '../camera-manager/manager';
|
|||||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import {
|
import {
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
FrigateCardConfig,
|
||||||
FrigateCardConfig,
|
FrigateCardCustomAction,
|
||||||
FrigateCardCustomAction,
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { View } from '../view/view';
|
import { View } from '../view/view';
|
||||||
import { createFrigateCardCustomAction } from './action';
|
import { createFrigateCardCustomAction } from './action';
|
||||||
@@ -388,10 +388,28 @@ export class MenuButtonController {
|
|||||||
...config.menu.buttons.screenshot,
|
...config.menu.buttons.screenshot,
|
||||||
type: 'custom:frigate-card-menu-icon',
|
type: 'custom:frigate-card-menu-icon',
|
||||||
title: localize('config.menu.buttons.screenshot'),
|
title: localize('config.menu.buttons.screenshot'),
|
||||||
tap_action: createFrigateCardCustomAction('screenshot') as FrigateCardCustomAction,
|
tap_action: createFrigateCardCustomAction(
|
||||||
|
'screenshot',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view.hasMultipleDisplayModes(visibleCameras.size)) {
|
||||||
|
const isGrid = view.isGrid();
|
||||||
|
const action = createFrigateCardCustomAction('display_mode_select', {
|
||||||
|
display_mode: isGrid ? 'single' : 'grid',
|
||||||
|
});
|
||||||
|
if (action) {
|
||||||
|
buttons.push({
|
||||||
|
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
|
||||||
|
...config.menu.buttons.display_mode,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.display_mode'),
|
||||||
|
tap_action: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
|
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
|
||||||
style: this._getStyleFromActions(config, view, button),
|
style: this._getStyleFromActions(config, view, button),
|
||||||
...button,
|
...button,
|
||||||
|
|||||||
+10
-5
@@ -141,15 +141,20 @@ export class Zoom {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public deactivate(): void {
|
public deactivate(): void {
|
||||||
const unregisterListener = (events: string[], func: (ev: Event) => void) => {
|
const unregisterListener = (
|
||||||
|
events: string[],
|
||||||
|
func: (ev: Event) => void,
|
||||||
|
options?: EventListenerOptions,
|
||||||
|
) => {
|
||||||
events.forEach((eventName) => {
|
events.forEach((eventName) => {
|
||||||
this._element.removeEventListener(eventName, func);
|
this._element.removeEventListener(eventName, func, options);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
unregisterListener(this._events['down'], this._downHandler);
|
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
||||||
unregisterListener(this._events['move'], this._moveHandler);
|
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
||||||
unregisterListener(this._events['up'], this._upHandler);
|
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
||||||
unregisterListener(['wheel'], this._wheelHandler);
|
unregisterListener(['wheel'], this._wheelHandler);
|
||||||
|
unregisterListener(['click'], this._clickHandler, { capture: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,151 @@
|
|||||||
import clone from 'lodash-es/clone.js';
|
|
||||||
import { isSuperset } from '../utils/basic.js';
|
import { isSuperset } from '../utils/basic.js';
|
||||||
import { ViewMedia } from './media.js';
|
import { ViewMedia } from './media.js';
|
||||||
|
|
||||||
export class MediaQueriesResults {
|
type CameraResultSlices = Map<string, ResultSlice>;
|
||||||
protected _results: ViewMedia[] | null = null;
|
type SelectApproach = 'first' | 'last';
|
||||||
protected _resultsTimestamp: Date | null = null;
|
|
||||||
protected _selectedIndex: number | null = null;
|
|
||||||
|
|
||||||
constructor(results?: ViewMedia[], selectedIndex?: number | null) {
|
interface ResultSliceOptions {
|
||||||
if (results) {
|
results?: ViewMedia[];
|
||||||
this.setResults(results);
|
selectedIndex?: number | null;
|
||||||
|
selectApproach?: SelectApproach;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResultSlice {
|
||||||
|
protected _results: ViewMedia[];
|
||||||
|
protected _selectedIndex: number | null;
|
||||||
|
|
||||||
|
constructor(options?: ResultSliceOptions) {
|
||||||
|
this._results = options?.results ?? [];
|
||||||
|
this._selectedIndex = this._getInitialSelectedIndex(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getInitialSelectedIndex(options?: ResultSliceOptions): number | null {
|
||||||
|
if (options?.selectedIndex !== undefined && options?.selectedIndex !== null) {
|
||||||
|
return options.selectedIndex;
|
||||||
}
|
}
|
||||||
if (selectedIndex !== undefined) {
|
if (options?.results && options.results.length) {
|
||||||
this.selectResult(selectedIndex);
|
if (!options?.selectApproach || options?.selectApproach === 'last') {
|
||||||
|
return options.results.length - 1;
|
||||||
|
} else if (options.selectApproach === 'first') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone(): ResultSlice {
|
||||||
|
return new ResultSlice({
|
||||||
|
results: this._results,
|
||||||
|
selectedIndex: this._selectedIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getResults(): ViewMedia[] {
|
||||||
|
return this._results;
|
||||||
|
}
|
||||||
|
public getSelectedIndex(): number | null {
|
||||||
|
return this._selectedIndex;
|
||||||
|
}
|
||||||
|
public getResultsCount(): number {
|
||||||
|
return this.getResults().length;
|
||||||
|
}
|
||||||
|
public hasResults(): boolean {
|
||||||
|
return this.getResultsCount() !== 0;
|
||||||
|
}
|
||||||
|
public getResult(index?: number): ViewMedia | null {
|
||||||
|
return index === undefined ? null : this._results[index];
|
||||||
|
}
|
||||||
|
public getSelectedResult(): ViewMedia | null {
|
||||||
|
const index = this.getSelectedIndex();
|
||||||
|
return index !== null ? this.getResult(index) : null;
|
||||||
|
}
|
||||||
|
public hasSelectedResult(): boolean {
|
||||||
|
return this.getSelectedResult() !== null;
|
||||||
|
}
|
||||||
|
public resetSelectedResult(): void {
|
||||||
|
this._selectedIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public selectIndex(index: number | null): void {
|
||||||
|
if (index === null || (index >= 0 && index < this._results.length)) {
|
||||||
|
this._selectedIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public selectResultIfFound(func: (media: ViewMedia) => boolean): void {
|
||||||
|
for (const [index, result] of this._results.entries()) {
|
||||||
|
if (func(result)) {
|
||||||
|
this.selectIndex(index);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public selectBestResult(func: (media: ViewMedia[]) => number | null): void {
|
||||||
|
const resultIndex = func(this._results);
|
||||||
|
if (resultIndex !== null) {
|
||||||
|
this.selectIndex(resultIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultSliceSelectionCriteria {
|
||||||
|
main?: boolean;
|
||||||
|
cameraID?: string;
|
||||||
|
allCameras?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaQueriesResults {
|
||||||
|
protected _resultsTimestamp: Date | null = null;
|
||||||
|
protected _main: ResultSlice;
|
||||||
|
protected _cameras: CameraResultSlices = new Map();
|
||||||
|
|
||||||
|
constructor(options?: ResultSliceOptions) {
|
||||||
|
this._resultsTimestamp = new Date();
|
||||||
|
this._main = new ResultSlice(options);
|
||||||
|
this._buildByCameraSlices(options?.selectApproach);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _buildByCameraSlices(selectApproach?: SelectApproach): void {
|
||||||
|
const cameraMap: Map<string, ViewMedia[]> = new Map();
|
||||||
|
for (const result of this._main.getResults()) {
|
||||||
|
const cameraID = result.getCameraID();
|
||||||
|
const media: ViewMedia[] = cameraMap.get(cameraID) ?? [];
|
||||||
|
media.push(result);
|
||||||
|
cameraMap.set(cameraID, media);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [cameraID, media] of cameraMap.entries()) {
|
||||||
|
this._cameras.set(
|
||||||
|
cameraID,
|
||||||
|
new ResultSlice({
|
||||||
|
results: media,
|
||||||
|
selectApproach: selectApproach,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public clone(): MediaQueriesResults {
|
public clone(): MediaQueriesResults {
|
||||||
// Shallow clone -- will reuse the same _results object (as there are no
|
// Shallow clone -- will reuse the same results object (as there are no
|
||||||
// methods that support modification of the results themselves, and since
|
// methods that support modification of the results themselves, and since
|
||||||
// changing the selectedIndex on a consistent set of results is a common
|
// changing the index on a consistent set of results is a very common
|
||||||
// operation).
|
// operation).
|
||||||
return clone(this);
|
const copy = new MediaQueriesResults();
|
||||||
|
copy._resultsTimestamp = this._resultsTimestamp;
|
||||||
|
copy._main = this._main.clone();
|
||||||
|
|
||||||
|
for (const [cameraID, slice] of this._cameras.entries()) {
|
||||||
|
copy._cameras.set(cameraID, slice.clone());
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public isSupersetOf(that: MediaQueriesResults): boolean {
|
public isSupersetOf(that: MediaQueriesResults): boolean {
|
||||||
if (!this._results || !that._results) {
|
const thisMediaIDs = new Set(this._main.getResults()?.map((media) => media.getID()));
|
||||||
return false;
|
const thatMediaIDs = new Set(that._main.getResults()?.map((media) => media.getID()));
|
||||||
}
|
|
||||||
|
|
||||||
const thisMediaIDs = new Set(this._results.map((media) => media.getID()));
|
|
||||||
const thatMediaIDs = new Set(that._results.map((media) => media.getID()));
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!thisMediaIDs ||
|
!thisMediaIDs.size ||
|
||||||
!thatMediaIDs ||
|
!thatMediaIDs.size ||
|
||||||
// If either media sets contain a null identifier (i.e. a media item with
|
// If either media sets contain a null identifier (i.e. a media item with
|
||||||
// no ID) we must assume this is not a subset as multiple media items may
|
// no ID) we must assume this is not a subset as multiple media items may
|
||||||
// reduce to the same null identifier above.
|
// reduce to the same null identifier above.
|
||||||
@@ -46,68 +157,105 @@ export class MediaQueriesResults {
|
|||||||
return isSuperset(thisMediaIDs, thatMediaIDs);
|
return isSuperset(thisMediaIDs, thatMediaIDs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getResults(): ViewMedia[] | null {
|
public getCameraIDs(): Set<string> {
|
||||||
return this._results;
|
return new Set(this._cameras.keys());
|
||||||
}
|
}
|
||||||
public getResultsCount(): number {
|
|
||||||
return this._results?.length ?? 0;
|
public getSlice(cameraID?: string): ResultSlice | null {
|
||||||
|
return cameraID ? this._cameras.get(cameraID) ?? null : this._main;
|
||||||
}
|
}
|
||||||
public hasResults(): boolean {
|
|
||||||
return !!this._results;
|
public getResults(cameraID?: string): ViewMedia[] | null {
|
||||||
|
return this.getSlice(cameraID)?.getResults() ?? null;
|
||||||
}
|
}
|
||||||
public setResults(results: ViewMedia[]) {
|
public getResultsCount(cameraID?: string): number {
|
||||||
this._results = results;
|
return this.getSlice(cameraID)?.getResultsCount() ?? 0;
|
||||||
this._resultsTimestamp = new Date();
|
|
||||||
}
|
}
|
||||||
public getResult(index?: number): ViewMedia | null {
|
public hasResults(cameraID?: string): boolean {
|
||||||
if (!this._results || index === undefined) {
|
return this.getSlice(cameraID)?.getResultsCount() !== 0;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this._results[index];
|
|
||||||
}
|
}
|
||||||
public getSelectedResult(): ViewMedia | null {
|
public getResult(index?: number, cameraID?: string): ViewMedia | null {
|
||||||
return this._selectedIndex === null ? null : this.getResult(this._selectedIndex);
|
return this.getSlice(cameraID)?.getResult(index) ?? null;
|
||||||
}
|
}
|
||||||
public getSelectedIndex(): number | null {
|
public getSelectedIndex(cameraID?: string): number | null {
|
||||||
return this._selectedIndex;
|
return this.getSlice(cameraID)?.getSelectedIndex() ?? null;
|
||||||
}
|
}
|
||||||
public hasSelectedResult(): boolean {
|
public getSelectedResult(cameraID?: string): ViewMedia | null {
|
||||||
return this.getSelectedResult() !== null;
|
return this.getSlice(cameraID)?.getSelectedResult() ?? null;
|
||||||
}
|
}
|
||||||
public resetSelectedResult(): MediaQueriesResults {
|
public hasSelectedResult(cameraID?: string): boolean {
|
||||||
this._selectedIndex = null;
|
return this.getSlice(cameraID)?.hasSelectedResult() ?? false;
|
||||||
|
}
|
||||||
|
public resetSelectedResult(cameraID?: string): MediaQueriesResults {
|
||||||
|
this.getSlice(cameraID)?.resetSelectedResult();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
public getResultsTimestamp(): Date | null {
|
public getResultsTimestamp(): Date | null {
|
||||||
return this._resultsTimestamp;
|
return this._resultsTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public selectResult(index: number | null): MediaQueriesResults {
|
public selectIndex(index: number, cameraID?: string): MediaQueriesResults {
|
||||||
if (
|
this.getSlice(cameraID)?.selectIndex(index);
|
||||||
index === null ||
|
if (!cameraID) {
|
||||||
(this._results && index >= 0 && index < this._results.length)
|
// If the main selection is changed, it must also change the matching
|
||||||
) {
|
// camera selection.
|
||||||
this._selectedIndex = index;
|
this.demoteMainSelectionToCameraSelection();
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults {
|
|
||||||
for (const [index, result] of this._results?.entries() ?? []) {
|
public demoteMainSelectionToCameraSelection(): MediaQueriesResults {
|
||||||
if (func(result)) {
|
const selected = this.getSelectedResult();
|
||||||
this._selectedIndex = index;
|
if (selected) {
|
||||||
break;
|
const cameraID = selected.getCameraID();
|
||||||
}
|
this.resetSelectedResult(cameraID);
|
||||||
|
this.selectResultIfFound((media) => media === selected, { cameraID: cameraID });
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public promoteCameraSelectionToMainSelection(cameraID: string): MediaQueriesResults {
|
||||||
|
const selected = this.getSelectedResult(cameraID);
|
||||||
|
this.resetSelectedResult();
|
||||||
|
this.selectResultIfFound((media) => media === selected);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getCameraIDsFromCriteria(
|
||||||
|
criteria?: ResultSliceSelectionCriteria,
|
||||||
|
): Set<string> | null {
|
||||||
|
return criteria?.allCameras
|
||||||
|
? this.getCameraIDs()
|
||||||
|
: criteria?.cameraID
|
||||||
|
? new Set([criteria.cameraID])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public selectResultIfFound(
|
||||||
|
func: (media: ViewMedia) => boolean,
|
||||||
|
criteria?: ResultSliceSelectionCriteria,
|
||||||
|
): MediaQueriesResults {
|
||||||
|
if (!criteria || criteria?.main) {
|
||||||
|
this._main.selectResultIfFound(func);
|
||||||
|
this.demoteMainSelectionToCameraSelection();
|
||||||
|
}
|
||||||
|
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
|
||||||
|
for (const cameraID of cameraIDs ?? []) {
|
||||||
|
this.getSlice(cameraID)?.selectResultIfFound(func);
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
public selectBestResult(
|
public selectBestResult(
|
||||||
func: (media: ViewMedia[]) => number | null,
|
func: (media: ViewMedia[]) => number | null,
|
||||||
|
criteria?: ResultSliceSelectionCriteria,
|
||||||
): MediaQueriesResults {
|
): MediaQueriesResults {
|
||||||
if (this._results) {
|
if (!criteria || criteria.main) {
|
||||||
const resultIndex = func(this._results);
|
this._main.selectBestResult(func);
|
||||||
if (resultIndex !== null) {
|
this.demoteMainSelectionToCameraSelection();
|
||||||
this._selectedIndex = resultIndex;
|
}
|
||||||
}
|
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
|
||||||
|
for (const cameraID of cameraIDs ?? []) {
|
||||||
|
this.getSlice(cameraID)?.selectBestResult(func);
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,24 @@ class MediaQueriesBase<T extends MediaQuery> {
|
|||||||
public setQueries(queries: T[]): void {
|
public setQueries(queries: T[]): void {
|
||||||
this._queries = queries;
|
this._queries = queries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasQueriesForCameraIDs(cameraIDs: Set<string>) {
|
||||||
|
for (const cameraID of cameraIDs) {
|
||||||
|
if (!this._queries?.some((query) => query.cameraIDs.has(cameraID))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
|
export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
|
||||||
public convertToClipsQueries(): void {
|
public convertToClipsQueries(): this {
|
||||||
for (const query of this._queries ?? []) {
|
for (const query of this._queries ?? []) {
|
||||||
delete query.hasSnapshot;
|
delete query.hasSnapshot;
|
||||||
query.hasClip = true;
|
query.hasClip = true;
|
||||||
}
|
}
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public clone(): EventMediaQueries {
|
public clone(): EventMediaQueries {
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ export class ViewMedia {
|
|||||||
this._mediaType = mediaType;
|
this._mediaType = mediaType;
|
||||||
this._cameraID = cameraID;
|
this._cameraID = cameraID;
|
||||||
}
|
}
|
||||||
public getContentType(): 'image' | 'video' {
|
|
||||||
return this._mediaType === 'snapshot' ? 'image' : 'video';
|
|
||||||
}
|
|
||||||
public getCameraID(): string {
|
public getCameraID(): string {
|
||||||
return this._cameraID;
|
return this._cameraID;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -1,5 +1,5 @@
|
|||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { ClipsOrSnapshots, FrigateCardView } from '../types.js';
|
import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js';
|
||||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||||
import { MediaQueries } from './media-queries';
|
import { MediaQueries } from './media-queries';
|
||||||
import { MediaQueriesClassifier } from './media-queries-classifier.js';
|
import { MediaQueriesClassifier } from './media-queries-classifier.js';
|
||||||
@@ -11,6 +11,7 @@ interface ViewEvolveParameters {
|
|||||||
query?: MediaQueries | null;
|
query?: MediaQueries | null;
|
||||||
queryResults?: MediaQueriesResults | null;
|
queryResults?: MediaQueriesResults | null;
|
||||||
context?: ViewContext | null;
|
context?: ViewContext | null;
|
||||||
|
displayMode?: ViewDisplayMode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewParameters extends ViewEvolveParameters {
|
export interface ViewParameters extends ViewEvolveParameters {
|
||||||
@@ -24,6 +25,7 @@ export class View {
|
|||||||
public query: MediaQueries | null;
|
public query: MediaQueries | null;
|
||||||
public queryResults: MediaQueriesResults | null;
|
public queryResults: MediaQueriesResults | null;
|
||||||
public context: ViewContext | null;
|
public context: ViewContext | null;
|
||||||
|
public displayMode: ViewDisplayMode | null;
|
||||||
|
|
||||||
constructor(params: ViewParameters) {
|
constructor(params: ViewParameters) {
|
||||||
this.view = params.view;
|
this.view = params.view;
|
||||||
@@ -31,6 +33,7 @@ export class View {
|
|||||||
this.query = params.query ?? null;
|
this.query = params.query ?? null;
|
||||||
this.queryResults = params.queryResults ?? null;
|
this.queryResults = params.queryResults ?? null;
|
||||||
this.context = params.context ?? null;
|
this.context = params.context ?? null;
|
||||||
|
this.displayMode = params.displayMode ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,6 +145,7 @@ export class View {
|
|||||||
query: this.query?.clone() ?? null,
|
query: this.query?.clone() ?? null,
|
||||||
queryResults: this.queryResults?.clone() ?? null,
|
queryResults: this.queryResults?.clone() ?? null,
|
||||||
context: this.context,
|
context: this.context,
|
||||||
|
displayMode: this.displayMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +164,8 @@ export class View {
|
|||||||
? params.queryResults
|
? params.queryResults
|
||||||
: this.queryResults?.clone() ?? null,
|
: this.queryResults?.clone() ?? null,
|
||||||
context: params.context !== undefined ? params.context : this.context,
|
context: params.context !== undefined ? params.context : this.context,
|
||||||
|
displayMode:
|
||||||
|
params.displayMode !== undefined ? params.displayMode : this.displayMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +191,17 @@ export class View {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public removeContextProperty(
|
||||||
|
contextKey: keyof ViewContext,
|
||||||
|
removeKey: PropertyKey,
|
||||||
|
): View {
|
||||||
|
const contextObj = this.context?.[contextKey];
|
||||||
|
if (contextObj) {
|
||||||
|
delete contextObj[removeKey];
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if current view matches a named view.
|
* Determine if current view matches a named view.
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +231,13 @@ export class View {
|
|||||||
return ['clip', 'snapshot', 'media', 'recording'].includes(this.view);
|
return ['clip', 'snapshot', 'media', 'recording'].includes(this.view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasMultipleDisplayModes(cameraCount?: number): boolean {
|
||||||
|
return (
|
||||||
|
(this.is('live') && (cameraCount ?? 0) > 1) ||
|
||||||
|
(this.isViewerView() && (this.queryResults?.getCameraIDs().size ?? 0) > 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the default media type for this view if available.
|
* Get the default media type for this view if available.
|
||||||
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
|
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
|
||||||
@@ -232,6 +256,10 @@ export class View {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isGrid(): boolean {
|
||||||
|
return this.displayMode === 'grid';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
|||||||
@@ -76,30 +76,26 @@ describe('capEndDate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('sortMedia', () => {
|
describe('sortMedia', () => {
|
||||||
const media_1 = new TestViewMedia(
|
const media_1 = new TestViewMedia({
|
||||||
'id-1',
|
id: 'id-1',
|
||||||
new Date('2023-04-29T14:25'),
|
startTime: new Date('2023-04-29T14:25'),
|
||||||
'clip',
|
cameraID: 'camera-1',
|
||||||
'camera-1',
|
});
|
||||||
);
|
const media_2 = new TestViewMedia({
|
||||||
const media_2 = new TestViewMedia(
|
id: 'id-2',
|
||||||
'id-2',
|
startTime: new Date('2023-04-29T14:26'),
|
||||||
new Date('2023-04-29T14:26'),
|
cameraID: 'camera-1',
|
||||||
'clip',
|
});
|
||||||
'camera-1',
|
const media_3_dup_id = new TestViewMedia({
|
||||||
);
|
id: 'id-2',
|
||||||
const media_3_dup_id = new TestViewMedia(
|
startTime: new Date('2023-04-29T14:26'),
|
||||||
'id-2',
|
cameraID: 'camera-1',
|
||||||
new Date('2023-04-29T14:26'),
|
});
|
||||||
'clip',
|
const media_4_no_id = new TestViewMedia({
|
||||||
'camera-1',
|
id: null,
|
||||||
);
|
startTime: new Date('2023-04-29T14:27'),
|
||||||
const media_4_no_id = new TestViewMedia(
|
cameraID: 'camera-1',
|
||||||
null,
|
});
|
||||||
new Date('2023-04-29T14:27'),
|
|
||||||
'clip',
|
|
||||||
'camera-1',
|
|
||||||
);
|
|
||||||
|
|
||||||
it('should sort sorted media', () => {
|
it('should sort sorted media', () => {
|
||||||
const media = [media_1, media_2];
|
const media = [media_1, media_2];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ConditionController,
|
ConditionController,
|
||||||
ConditionEvaluateRequestEvent,
|
ConditionEvaluateRequestEvent,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getOverriddenConfig,
|
getOverriddenConfig,
|
||||||
getOverridesByKey,
|
getOverridesByKey,
|
||||||
} from '../src/conditions';
|
} from '../src/conditions';
|
||||||
|
import { FrigateCardCondition } from '../src/types';
|
||||||
import { createCondition, createConfig, createStateEntity } from './test-utils';
|
import { createCondition, createConfig, createStateEntity } from './test-utils';
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
@@ -347,4 +348,14 @@ describe('ConditionController', () => {
|
|||||||
controller.destroy();
|
controller.destroy();
|
||||||
expect(removeEventListener).toBeCalled();
|
expect(removeEventListener).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should evaluate conditions with display mode', () => {
|
||||||
|
const controller = new ConditionController();
|
||||||
|
const condition: FrigateCardCondition = { display_mode: 'grid' };
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
controller.setState({ displayMode: 'grid' });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeTruthy();
|
||||||
|
controller.setState({ displayMode: 'single' });
|
||||||
|
expect(controller.evaluateCondition(condition)).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+71
-10
@@ -9,6 +9,7 @@ import {
|
|||||||
CameraConfigs,
|
CameraConfigs,
|
||||||
CameraManagerCameraCapabilities,
|
CameraManagerCameraCapabilities,
|
||||||
CameraManagerMediaCapabilities,
|
CameraManagerMediaCapabilities,
|
||||||
|
QueryType,
|
||||||
} from '../src/camera-manager/types';
|
} from '../src/camera-manager/types';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
@@ -16,10 +17,12 @@ import {
|
|||||||
FrigateCardCondition,
|
FrigateCardCondition,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
|
PerformanceConfig,
|
||||||
RawFrigateCardConfig,
|
RawFrigateCardConfig,
|
||||||
cameraConfigSchema,
|
cameraConfigSchema,
|
||||||
frigateCardConditionSchema,
|
frigateCardConditionSchema,
|
||||||
frigateCardConfigSchema,
|
frigateCardConfigSchema,
|
||||||
|
performanceConfigSchema,
|
||||||
} from '../src/types';
|
} from '../src/types';
|
||||||
import { Entity } from '../src/utils/ha/entity-registry/types';
|
import { Entity } from '../src/utils/ha/entity-registry/types';
|
||||||
import { ViewMedia, ViewMediaType } from '../src/view/media';
|
import { ViewMedia, ViewMediaType } from '../src/view/media';
|
||||||
@@ -126,11 +129,25 @@ export const createCameraManager = (options?: {
|
|||||||
const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]);
|
const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]);
|
||||||
vi.mocked(store.getCameras).mockReturnValue(configs);
|
vi.mocked(store.getCameras).mockReturnValue(configs);
|
||||||
vi.mocked(store.getVisibleCameras).mockReturnValue(configs);
|
vi.mocked(store.getVisibleCameras).mockReturnValue(configs);
|
||||||
|
vi.mocked(store.getVisibleCameraIDs).mockReturnValue(new Set(configs.keys()));
|
||||||
vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => {
|
vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => {
|
||||||
return configs.get(cameraID) ?? createCameraConfig();
|
return configs.get(cameraID) ?? createCameraConfig();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
vi.mocked(cameraManager.getStore).mockReturnValue(store);
|
vi.mocked(cameraManager.getStore).mockReturnValue(store);
|
||||||
|
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([
|
||||||
|
{
|
||||||
|
cameraIDs: new Set(['camera']),
|
||||||
|
type: QueryType.Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([
|
||||||
|
{
|
||||||
|
cameraIDs: new Set(['camera']),
|
||||||
|
type: QueryType.Recording,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
return cameraManager;
|
return cameraManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,21 +186,44 @@ export const createMediaLoadedInfo = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPerformanceConfig = (config: unknown): PerformanceConfig => {
|
||||||
|
return performanceConfigSchema.parse(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateViewMediaArray = (options?: {
|
||||||
|
cameraIDs?: string[];
|
||||||
|
count?: number;
|
||||||
|
}): ViewMedia[] => {
|
||||||
|
const media: ViewMedia[] = [];
|
||||||
|
for (let i = 0; i < (options?.count ?? 100); ++i) {
|
||||||
|
for (const cameraID of options?.cameraIDs ?? ['kitchen', 'office']) {
|
||||||
|
media.push(new TestViewMedia({ cameraID: cameraID, id: `id-${cameraID}-${i}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return media;
|
||||||
|
};
|
||||||
|
|
||||||
// ViewMedia itself has no native way to set startTime and ID that aren't linked
|
// ViewMedia itself has no native way to set startTime and ID that aren't linked
|
||||||
// to an engine.
|
// to an engine.
|
||||||
export class TestViewMedia extends ViewMedia {
|
export class TestViewMedia extends ViewMedia {
|
||||||
protected _id: string | null;
|
protected _id: string | null;
|
||||||
protected _startTime: Date;
|
protected _startTime: Date | null;
|
||||||
|
protected _endTime: Date | null;
|
||||||
|
protected _inProgress: boolean | null;
|
||||||
|
|
||||||
constructor(
|
constructor(options?: {
|
||||||
id: string | null,
|
id?: string | null;
|
||||||
startTime: Date,
|
startTime?: Date;
|
||||||
mediaType: ViewMediaType,
|
mediaType?: ViewMediaType;
|
||||||
cameraID: string,
|
cameraID?: string;
|
||||||
) {
|
endTime?: Date;
|
||||||
super(mediaType, cameraID);
|
inProgress?: boolean;
|
||||||
this._id = id;
|
}) {
|
||||||
this._startTime = startTime;
|
super(options?.mediaType ?? 'clip', options?.cameraID ?? 'camera');
|
||||||
|
this._id = options?.id !== undefined ? options.id : 'id';
|
||||||
|
this._startTime = options?.startTime ?? null;
|
||||||
|
this._endTime = options?.endTime ?? null;
|
||||||
|
this._inProgress = options?.inProgress !== undefined ? options.inProgress : false;
|
||||||
}
|
}
|
||||||
public getID(): string | null {
|
public getID(): string | null {
|
||||||
return this._id;
|
return this._id;
|
||||||
@@ -191,4 +231,25 @@ export class TestViewMedia extends ViewMedia {
|
|||||||
public getStartTime(): Date | null {
|
public getStartTime(): Date | null {
|
||||||
return this._startTime;
|
return this._startTime;
|
||||||
}
|
}
|
||||||
|
public getEndTime(): Date | null {
|
||||||
|
return this._endTime;
|
||||||
|
}
|
||||||
|
public inProgress(): boolean | null {
|
||||||
|
return this._inProgress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createResizeObserverImplementation = (): (() => void) => {
|
||||||
|
return () => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMutationObserverImplementation = (): (() => void) => {
|
||||||
|
return () => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -103,6 +103,24 @@ describe('createFrigateCardCustomAction', () => {
|
|||||||
card_id: 'card_id',
|
card_id: 'card_id',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create display mode action', () => {
|
||||||
|
expect(
|
||||||
|
createFrigateCardCustomAction('display_mode_select', {
|
||||||
|
display_mode: 'grid',
|
||||||
|
cardID: 'card_id',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'display_mode_select',
|
||||||
|
display_mode: 'grid',
|
||||||
|
card_id: 'card_id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create display mode action without display mode', () => {
|
||||||
|
expect(createFrigateCardCustomAction('display_mode_select')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getActionConfigGivenAction', () => {
|
describe('getActionConfigGivenAction', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
dayToDate,
|
dayToDate,
|
||||||
dispatchFrigateCardEvent,
|
dispatchFrigateCardEvent,
|
||||||
errorToConsole,
|
errorToConsole,
|
||||||
|
filterTruthy,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateAndTime,
|
formatDateAndTime,
|
||||||
getDurationString,
|
getDurationString,
|
||||||
@@ -227,7 +228,13 @@ describe('isValidDate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('setOrRemoveAttribute', () => {
|
describe('setOrRemoveAttribute', () => {
|
||||||
it('should set attribute', () => {
|
it('should set attribute without value', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
setOrRemoveAttribute(element, true, 'key');
|
||||||
|
expect(element.getAttribute('key')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set attribute with value', () => {
|
||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
setOrRemoveAttribute(element, true, 'key', 'value');
|
setOrRemoveAttribute(element, true, 'key', 'value');
|
||||||
expect(element.getAttribute('key')).toBe('value');
|
expect(element.getAttribute('key')).toBe('value');
|
||||||
@@ -240,3 +247,12 @@ describe('setOrRemoveAttribute', () => {
|
|||||||
expect(element.getAttribute('key')).toBeFalsy();
|
expect(element.getAttribute('key')).toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('filterTruthy', () => {
|
||||||
|
it('should return true for true', () => {
|
||||||
|
expect(filterTruthy(true)).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('should return false for false', () => {
|
||||||
|
expect(filterTruthy(false)).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import Masonry from 'masonry-layout';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { MediaLoadedInfo } from '../../src/types';
|
||||||
|
import {
|
||||||
|
MediaGridConstructorOptions,
|
||||||
|
MediaGridController,
|
||||||
|
} from '../../src/utils/media-grid-controller';
|
||||||
|
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
|
||||||
|
import {
|
||||||
|
createMutationObserverImplementation,
|
||||||
|
createResizeObserverImplementation,
|
||||||
|
} from '../test-utils';
|
||||||
|
|
||||||
|
vi.mock('lodash-es/throttle', () => ({
|
||||||
|
default: vi.fn((fn) => fn),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const masonry = mock<Masonry>();
|
||||||
|
vi.mock('masonry-layout', () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => {
|
||||||
|
return masonry;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createChildren = (childIDs?: string[], idAttribute?: string): HTMLElement[] => {
|
||||||
|
const children: HTMLElement[] = [];
|
||||||
|
for (let i = 0; i < (childIDs?.length ?? 3); ++i) {
|
||||||
|
const child = document.createElement('div');
|
||||||
|
if (childIDs) {
|
||||||
|
child.setAttribute(idAttribute ?? 'grid-id', childIDs[i]);
|
||||||
|
}
|
||||||
|
children.push(child);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setElementWidth = (element: HTMLElement, width: number): void => {
|
||||||
|
element.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||||
|
width: width,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createHost = (options?: {
|
||||||
|
children?: HTMLElement[];
|
||||||
|
width?: number;
|
||||||
|
}): HTMLElement => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
if (options?.children) {
|
||||||
|
host.append(...options.children);
|
||||||
|
}
|
||||||
|
// Default Lovelace card width is 492.
|
||||||
|
setElementWidth(host, options?.width ?? 492);
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSlotParent = (): HTMLElement => {
|
||||||
|
const parent = document.createElement('div');
|
||||||
|
parent.attachShadow({ mode: 'open' });
|
||||||
|
return parent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSlotHost = (options?: {
|
||||||
|
children?: HTMLElement[];
|
||||||
|
parent?: HTMLElement;
|
||||||
|
}): HTMLElement => {
|
||||||
|
const parent = options?.parent ?? createSlotParent();
|
||||||
|
const slot = document.createElement('slot');
|
||||||
|
parent.shadowRoot?.append(slot);
|
||||||
|
|
||||||
|
if (options?.children) {
|
||||||
|
// Children will automatically be slotted into the default slot.
|
||||||
|
parent.append(...options.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return slot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => {
|
||||||
|
return new MediaGridController(host, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerMutationObserver = (): void => {
|
||||||
|
const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[0][0];
|
||||||
|
mutationObserverTrigger([], mock<MutationObserver>());
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerResizeObserver = (cellOrHost: 'cell' | 'host'): void => {
|
||||||
|
const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[
|
||||||
|
cellOrHost === 'cell' ? 0 : 1
|
||||||
|
][0];
|
||||||
|
resizeObserverTrigger([], mock<ResizeObserver>());
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('MediaGridController', () => {
|
||||||
|
const mediaLoadedInfo: MediaLoadedInfo = {
|
||||||
|
width: 10,
|
||||||
|
height: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
global.ResizeObserver = vi
|
||||||
|
.fn()
|
||||||
|
// Caution: Order must match the order of initialization in
|
||||||
|
// media-grid-controller.ts .
|
||||||
|
.mockImplementationOnce(createResizeObserverImplementation())
|
||||||
|
.mockImplementationOnce(createResizeObserverImplementation());
|
||||||
|
|
||||||
|
global.MutationObserver = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(createMutationObserverImplementation());
|
||||||
|
//global.MutationObserver = mock<MutationObserver>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be constructable', () => {
|
||||||
|
const controller = createController(createHost());
|
||||||
|
expect(controller).toBeTruthy();
|
||||||
|
expect(masonry.layout).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set grid contents correctly from regular elements', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createHost({ children: children });
|
||||||
|
const controller = createController(host);
|
||||||
|
expect(controller.getGridContents()).toEqual(
|
||||||
|
new Map([
|
||||||
|
['0', children[0]],
|
||||||
|
['1', children[1]],
|
||||||
|
['2', children[2]],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(controller.getGridSize()).toBe(3);
|
||||||
|
expect(masonry.layout).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set grid contents correctly from slotted elements', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createSlotHost({ children: children });
|
||||||
|
const controller = createController(host);
|
||||||
|
expect(controller.getGridContents()).toEqual(
|
||||||
|
new Map([
|
||||||
|
['0', children[0]],
|
||||||
|
['1', children[1]],
|
||||||
|
['2', children[2]],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(controller.getGridSize()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select element', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const controller = createController(createSlotHost({ children: children }));
|
||||||
|
|
||||||
|
// All children should be unselected.
|
||||||
|
expect(controller.getSelected()).toBeNull();
|
||||||
|
for (const child of children) {
|
||||||
|
expect(child.getAttribute('selected')).toBeNull();
|
||||||
|
expect(child.getAttribute('unselected')).toEqual('');
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.selectCell('0');
|
||||||
|
expect(controller.getSelected()).toBe('0');
|
||||||
|
|
||||||
|
// 1st child should now be selected.
|
||||||
|
expect(children[0].getAttribute('selected')).toEqual('');
|
||||||
|
expect(children[0].getAttribute('unselected')).toBeNull();
|
||||||
|
|
||||||
|
// 2nd and 3rd should be unselected.
|
||||||
|
for (const child of children.slice(1)) {
|
||||||
|
expect(child.getAttribute('selected')).toBeNull();
|
||||||
|
expect(child.getAttribute('unselected')).toEqual('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-select element', () => {
|
||||||
|
const controller = createController(createSlotHost({ children: createChildren() }));
|
||||||
|
|
||||||
|
// All children should be unselected.
|
||||||
|
expect(controller.getSelected()).toBeNull();
|
||||||
|
|
||||||
|
controller.selectCell('0');
|
||||||
|
expect(controller.getSelected()).toBe('0');
|
||||||
|
|
||||||
|
controller.selectCell('0');
|
||||||
|
expect(controller.getSelected()).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dispatch media loaded info on selection', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createSlotHost({ children: children });
|
||||||
|
const controller = createController(host);
|
||||||
|
|
||||||
|
const mediaLoadedInfoHandler = vi.fn();
|
||||||
|
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||||
|
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||||
|
|
||||||
|
// Nothing is selected, so the event should not have propagated.
|
||||||
|
expect(mediaLoadedInfoHandler).not.toBeCalled();
|
||||||
|
|
||||||
|
controller.selectCell('0');
|
||||||
|
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
detail: mediaLoadedInfo,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unselect', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createSlotHost({ children: children });
|
||||||
|
const controller = createController(host);
|
||||||
|
|
||||||
|
const unselectedHandler = vi.fn();
|
||||||
|
const unloadMediaHandler = vi.fn();
|
||||||
|
host.addEventListener('frigate-card:media-grid:unselected', unselectedHandler);
|
||||||
|
host.addEventListener('frigate-card:media:unloaded', unloadMediaHandler);
|
||||||
|
|
||||||
|
controller.selectCell('0');
|
||||||
|
expect(controller.getSelected()).toBe('0');
|
||||||
|
|
||||||
|
// Unselect all elements.
|
||||||
|
controller.unselectAll();
|
||||||
|
|
||||||
|
// Expect selected to now be null.
|
||||||
|
expect(controller.getSelected()).toBeNull();
|
||||||
|
|
||||||
|
// Expect styles to have been updated.
|
||||||
|
for (const child of children) {
|
||||||
|
expect(child.getAttribute('selected')).toBeNull();
|
||||||
|
expect(child.getAttribute('unselected')).toEqual('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect handlers to have been called.
|
||||||
|
expect(unselectedHandler).toBeCalled();
|
||||||
|
expect(unloadMediaHandler).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select in constructor', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createSlotHost({ children: children });
|
||||||
|
const controller = createController(host, { selected: '2' });
|
||||||
|
expect(controller.getSelected()).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect grid attribute option', () => {
|
||||||
|
const children = createChildren(['one', 'two', 'three'], 'test-id');
|
||||||
|
const host = createSlotHost({ children: children });
|
||||||
|
const controller = createController(host, { idAttribute: 'test-id' });
|
||||||
|
expect(controller.getGridContents()).toEqual(
|
||||||
|
new Map([
|
||||||
|
['one', children[0]],
|
||||||
|
['two', children[1]],
|
||||||
|
['three', children[2]],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should destroy', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createSlotHost({ children: children });
|
||||||
|
const controller = createController(host);
|
||||||
|
expect(controller.getGridSize()).toBe(3);
|
||||||
|
controller.destroy();
|
||||||
|
expect(controller.getGridSize()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace children when they change', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createHost({ children: children });
|
||||||
|
const controller = createController(host, { selected: '1' });
|
||||||
|
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||||
|
|
||||||
|
expect(controller.getSelected()).toBe('1');
|
||||||
|
expect(controller.getGridSize()).toBe(3);
|
||||||
|
|
||||||
|
children.forEach((child) => host.removeChild(child));
|
||||||
|
const newChildren = createChildren(['one', 'two', 'three']);
|
||||||
|
newChildren.forEach((child) => host.appendChild(child));
|
||||||
|
|
||||||
|
triggerMutationObserver();
|
||||||
|
|
||||||
|
expect(controller.getGridContents()).toEqual(
|
||||||
|
new Map([
|
||||||
|
['one', newChildren[0]],
|
||||||
|
['two', newChildren[1]],
|
||||||
|
['three', newChildren[2]],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(controller.getSelected()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct masonry correctly', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createHost({ children: children });
|
||||||
|
createController(host);
|
||||||
|
expect(Masonry).toBeCalledWith(
|
||||||
|
host,
|
||||||
|
expect.objectContaining({
|
||||||
|
initLayout: false,
|
||||||
|
percentPosition: true,
|
||||||
|
transitionDuration: '0.3s',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set default column size correctly', () => {
|
||||||
|
const host = createHost({ children: createChildren() });
|
||||||
|
createController(host);
|
||||||
|
expect(Masonry).toBeCalledWith(
|
||||||
|
host,
|
||||||
|
expect.objectContaining({
|
||||||
|
columnWidth: 246,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect exact columns', () => {
|
||||||
|
const host = createHost({ children: createChildren(), width: 2000 });
|
||||||
|
const controller = createController(host);
|
||||||
|
controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 });
|
||||||
|
|
||||||
|
// Will have been called once on construction, and then again when the
|
||||||
|
// number of columns changes.
|
||||||
|
expect(Masonry).toBeCalledTimes(2);
|
||||||
|
expect(Masonry).toBeCalledWith(
|
||||||
|
host,
|
||||||
|
expect.objectContaining({
|
||||||
|
columnWidth: 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
|
||||||
|
'1000px',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect selected width factor', () => {
|
||||||
|
const host = createHost({ children: createChildren(), width: 2000 });
|
||||||
|
const controller = createController(host);
|
||||||
|
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
|
||||||
|
expect(
|
||||||
|
host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
|
||||||
|
).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select cell with interacted with', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createHost({ children: children, width: 2000 });
|
||||||
|
const controller = createController(host);
|
||||||
|
|
||||||
|
expect(controller.getSelected()).toBeNull();
|
||||||
|
|
||||||
|
const touchEvent = new TouchEvent('touchend');
|
||||||
|
children[1].dispatchEvent(touchEvent);
|
||||||
|
|
||||||
|
expect(controller.getSelected()).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-layout when child size changes', () => {
|
||||||
|
createController(createHost({ children: createChildren() }));
|
||||||
|
|
||||||
|
vi.mocked(masonry.layout)?.mockClear();
|
||||||
|
triggerResizeObserver('cell');
|
||||||
|
expect(masonry.layout).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-create masonry when host size changes', () => {
|
||||||
|
const children = createChildren();
|
||||||
|
const host = createHost({ children: children });
|
||||||
|
const controller = createController(host);
|
||||||
|
expect(Masonry).toBeCalledWith(
|
||||||
|
host,
|
||||||
|
expect.objectContaining({
|
||||||
|
columnWidth: 246,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||||
|
|
||||||
|
// Clear mock state.
|
||||||
|
vi.mocked(Masonry).mockClear();
|
||||||
|
vi.mocked(masonry.layout)?.mockClear();
|
||||||
|
|
||||||
|
// Resize the host.
|
||||||
|
setElementWidth(host, 2000);
|
||||||
|
triggerResizeObserver('host');
|
||||||
|
|
||||||
|
// Masonry should be reconstructed, styles set and layout called.
|
||||||
|
expect(Masonry).toBeCalledWith(
|
||||||
|
host,
|
||||||
|
expect.objectContaining({
|
||||||
|
columnWidth: 667,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
|
||||||
|
expect(masonry.layout).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import add from 'date-fns/add';
|
||||||
|
import sub from 'date-fns/sub';
|
||||||
|
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
CameraConfigs,
|
||||||
|
PartialRecordingQuery,
|
||||||
|
QueryType,
|
||||||
|
} from '../../src/camera-manager/types';
|
||||||
|
import { setify } from '../../src/utils/basic';
|
||||||
|
import {
|
||||||
|
changeViewToRecentEventsForCameraAndDependents,
|
||||||
|
changeViewToRecentRecordingForCameraAndDependents,
|
||||||
|
createQueriesForRecordingsView,
|
||||||
|
executeMediaQueryForView,
|
||||||
|
findBestMediaIndex,
|
||||||
|
} from '../../src/utils/media-to-view';
|
||||||
|
import { ViewMedia } from '../../src/view/media';
|
||||||
|
import { EventMediaQueries } from '../../src/view/media-queries';
|
||||||
|
import {
|
||||||
|
createCameraManager,
|
||||||
|
createHASS,
|
||||||
|
createPerformanceConfig,
|
||||||
|
createView,
|
||||||
|
TestViewMedia,
|
||||||
|
} from '../test-utils';
|
||||||
|
|
||||||
|
vi.mock('../../src/camera-manager/manager.js');
|
||||||
|
|
||||||
|
const createElementListenForView = (): {
|
||||||
|
element: HTMLElement;
|
||||||
|
viewHandler: Mock<any, any>;
|
||||||
|
messageHandler: Mock<any, any>;
|
||||||
|
} => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const viewHandler = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:view:change', viewHandler);
|
||||||
|
|
||||||
|
const messageHandler = vi.fn();
|
||||||
|
element.addEventListener('frigate-card:message', messageHandler);
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: element,
|
||||||
|
viewHandler: viewHandler,
|
||||||
|
messageHandler: messageHandler,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMediaFromHandlerCall = (handler: Mock<any, any>): ViewMedia[] | null => {
|
||||||
|
return handler.mock.calls[0][0].detail.queryResults.getResults();
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateViewMedia = (
|
||||||
|
index: number,
|
||||||
|
base: Date,
|
||||||
|
durationSeconds: number,
|
||||||
|
): ViewMedia => {
|
||||||
|
return new TestViewMedia({
|
||||||
|
id: `id-${index}`,
|
||||||
|
startTime: base,
|
||||||
|
endTime: add(base, { seconds: durationSeconds }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing without camera config for selected camera', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager({ configs: new Map() });
|
||||||
|
|
||||||
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing without camera configs for all cameras', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager({ configs: new Map() });
|
||||||
|
|
||||||
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
{
|
||||||
|
allCameras: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing unless queries can be created', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null);
|
||||||
|
|
||||||
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
{
|
||||||
|
mediaType: 'clips',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dispatch new view on success', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
|
||||||
|
const mediaArray = [new ViewMedia('clip', 'camera')];
|
||||||
|
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
|
||||||
|
|
||||||
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
{
|
||||||
|
targetView: 'clips',
|
||||||
|
select: 'latest',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).toBeCalled();
|
||||||
|
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dispatch error message on fail', async () => {
|
||||||
|
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||||
|
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error());
|
||||||
|
|
||||||
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||||
|
expect(elementHandler.messageHandler).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect media chunk size', async () => {
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
|
||||||
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
|
createElementListenForView().element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{
|
||||||
|
performance: createPerformanceConfig({
|
||||||
|
features: {
|
||||||
|
media_chunk_size: 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
createView(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cameraManager.generateDefaultEventQueries).toBeCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
limit: 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respect request for media type', () => {
|
||||||
|
it.each([
|
||||||
|
['snapshots' as const, 'hasSnapshot'],
|
||||||
|
['clips' as const, 'hasClip'],
|
||||||
|
])('%s', async (mediaType, queryParameter) => {
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
|
||||||
|
await changeViewToRecentEventsForCameraAndDependents(
|
||||||
|
createElementListenForView().element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
{
|
||||||
|
mediaType: mediaType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cameraManager.generateDefaultEventQueries).toBeCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
[queryParameter]: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('executeMediaQueryForView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not execute empty queries', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraConfigs: CameraConfigs = new Map();
|
||||||
|
const cameraManager = createCameraManager({ configs: cameraConfigs });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await executeMediaQueryForView(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
createView(),
|
||||||
|
new EventMediaQueries(),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select time-based result', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraConfigs: CameraConfigs = new Map();
|
||||||
|
const cameraManager = createCameraManager({ configs: cameraConfigs });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const mediaArray = [
|
||||||
|
generateViewMedia(0, now, 60),
|
||||||
|
generateViewMedia(1, now, 120),
|
||||||
|
generateViewMedia(2, now, 10),
|
||||||
|
];
|
||||||
|
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
|
||||||
|
|
||||||
|
const view = await executeMediaQueryForView(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
createView(),
|
||||||
|
new EventMediaQueries(
|
||||||
|
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
select: 'time',
|
||||||
|
targetTime: add(now, { seconds: 30 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should select the longest event.
|
||||||
|
expect(view?.queryResults?.getSelectedIndex()).toBe(1);
|
||||||
|
expect(view?.queryResults?.getResults()).toBe(mediaArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select nothing when time-based selection does not match', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraConfigs: CameraConfigs = new Map();
|
||||||
|
const cameraManager = createCameraManager({ configs: cameraConfigs });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const mediaArray = [
|
||||||
|
generateViewMedia(0, now, 60),
|
||||||
|
generateViewMedia(1, now, 120),
|
||||||
|
generateViewMedia(2, now, 10),
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
|
||||||
|
|
||||||
|
const view = await executeMediaQueryForView(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
createView(),
|
||||||
|
new EventMediaQueries(
|
||||||
|
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
select: 'time',
|
||||||
|
targetTime: sub(now, { seconds: 30 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should leave selection untouched (last item will remain selected).
|
||||||
|
expect(view?.queryResults?.getSelectedIndex()).toBe(2);
|
||||||
|
expect(view?.queryResults?.getResults()).toBe(mediaArray);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('changeViewToRecentRecordingForCameraAndDependents', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing without camera config for selected camera', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager({ configs: new Map() });
|
||||||
|
|
||||||
|
await changeViewToRecentRecordingForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing without camera configs for all cameras', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager({ configs: new Map() });
|
||||||
|
|
||||||
|
await changeViewToRecentRecordingForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
{
|
||||||
|
allCameras: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing unless queries can be created', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue(null);
|
||||||
|
|
||||||
|
await changeViewToRecentRecordingForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dispatch new view on success', async () => {
|
||||||
|
const elementHandler = createElementListenForView();
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
|
||||||
|
const mediaArray = [new ViewMedia('recording', 'camera')];
|
||||||
|
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
|
||||||
|
|
||||||
|
await changeViewToRecentRecordingForCameraAndDependents(
|
||||||
|
elementHandler.element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
createView(),
|
||||||
|
{
|
||||||
|
targetView: 'recordings',
|
||||||
|
select: 'latest',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(elementHandler.viewHandler).toBeCalled();
|
||||||
|
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect media chunk size', async () => {
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
|
||||||
|
await changeViewToRecentRecordingForCameraAndDependents(
|
||||||
|
createElementListenForView().element,
|
||||||
|
createHASS(),
|
||||||
|
cameraManager,
|
||||||
|
{
|
||||||
|
performance: createPerformanceConfig({
|
||||||
|
features: {
|
||||||
|
media_chunk_size: 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
createView(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
limit: 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('createQueriesForRecordingsView', () => {
|
||||||
|
it('should respect start and end date in recording query', async () => {
|
||||||
|
const cameraManager = createCameraManager({ configs: new Map() });
|
||||||
|
|
||||||
|
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockImplementation(
|
||||||
|
(cameraIDs: string | Set<string>, partialQuery?: PartialRecordingQuery) => [
|
||||||
|
{
|
||||||
|
cameraIDs: setify(cameraIDs),
|
||||||
|
type: QueryType.Recording,
|
||||||
|
...partialQuery,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = new Date('2023-04-29T14:00:00');
|
||||||
|
const end = new Date('2023-04-29T14:59:59');
|
||||||
|
|
||||||
|
const queries = createQueriesForRecordingsView(
|
||||||
|
cameraManager,
|
||||||
|
{},
|
||||||
|
new Set(['camera']),
|
||||||
|
{
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queries?.getQueries()).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('findBestMediaIndex', () => {
|
||||||
|
it('should find best media index', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const mediaArray = [
|
||||||
|
generateViewMedia(0, now, 60),
|
||||||
|
generateViewMedia(1, now, 120),
|
||||||
|
generateViewMedia(2, now, 10),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findBestMediaIndex(mediaArray, add(now, { seconds: 30 }))).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended';
|
|||||||
import { FrigateCardMediaPlayer } from '../../src/types.js';
|
import { FrigateCardMediaPlayer } from '../../src/types.js';
|
||||||
import {
|
import {
|
||||||
FrigateCardHTMLVideoElement,
|
FrigateCardHTMLVideoElement,
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
playMediaMutingIfNecessary,
|
playMediaMutingIfNecessary,
|
||||||
setControlsOnVideo,
|
setControlsOnVideo,
|
||||||
@@ -108,3 +109,9 @@ describe('playMediaMutingIfNecessary', () => {
|
|||||||
expect(player.mute).toBeCalled();
|
expect(player.mute).toBeCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('constants', () => {
|
||||||
|
it('MEDIA_LOAD_CONTROLS_HIDE_SECONDS', () => {
|
||||||
|
expect(MEDIA_LOAD_CONTROLS_HIDE_SECONDS).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
|
ViewDisplayMode,
|
||||||
} from '../../src/types';
|
} from '../../src/types';
|
||||||
import { createFrigateCardCustomAction } from '../../src/utils/action';
|
import { createFrigateCardCustomAction } from '../../src/utils/action';
|
||||||
import { MenuButtonController } from '../../src/utils/menu-controller';
|
import { MenuButtonController } from '../../src/utils/menu-controller';
|
||||||
@@ -67,6 +68,7 @@ const calculateButtons = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
describe('MenuButtonController', () => {
|
describe('MenuButtonController', () => {
|
||||||
let controller: MenuButtonController;
|
let controller: MenuButtonController;
|
||||||
const dynamicButton: MenuButton = {
|
const dynamicButton: MenuButton = {
|
||||||
@@ -612,7 +614,10 @@ describe('MenuButtonController', () => {
|
|||||||
|
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
const view = createView({
|
const view = createView({
|
||||||
queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0),
|
queryResults: new MediaQueriesResults({
|
||||||
|
results: [new ViewMedia('clip', 'camera-1')],
|
||||||
|
selectedIndex: 0,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
|
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
|
||||||
createMediaCapabilities({ canDownload: true }),
|
createMediaCapabilities({ canDownload: true }),
|
||||||
@@ -640,7 +645,10 @@ describe('MenuButtonController', () => {
|
|||||||
|
|
||||||
const cameraManager = createCameraManager();
|
const cameraManager = createCameraManager();
|
||||||
const view = createView({
|
const view = createView({
|
||||||
queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0),
|
queryResults: new MediaQueriesResults({
|
||||||
|
results: [new ViewMedia('clip', 'camera-1')],
|
||||||
|
selectedIndex: 0,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
|
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
|
||||||
createMediaCapabilities({ canDownload: true }),
|
createMediaCapabilities({ canDownload: true }),
|
||||||
@@ -1084,6 +1092,27 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should have grid button when display mode is', () => {
|
||||||
|
it.each([['single' as const], ['grid' as const]])(
|
||||||
|
'%s',
|
||||||
|
(displayMode: ViewDisplayMode) => {
|
||||||
|
const view = createView({ view: 'live', displayMode: displayMode });
|
||||||
|
expect(calculateButtons(controller, { view: view })).toContainEqual({
|
||||||
|
icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
|
||||||
|
enabled: true,
|
||||||
|
priority: 50,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: 'Display mode',
|
||||||
|
tap_action: {
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
frigate_card_action: 'display_mode_select',
|
||||||
|
display_mode: displayMode === 'single' ? 'grid' : 'single',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle dynamic buttons', () => {
|
it('should handle dynamic buttons', () => {
|
||||||
const button: MenuButton = {
|
const button: MenuButton = {
|
||||||
...dynamicButton,
|
...dynamicButton,
|
||||||
|
|||||||
@@ -62,32 +62,30 @@ describe('generateScreenshotTitle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should get title for media viewer view with id', () => {
|
it('should get title for media viewer view with id', () => {
|
||||||
const media = new TestViewMedia(
|
const media = new TestViewMedia({
|
||||||
'id1',
|
id: 'id1',
|
||||||
new Date('2023-06-16T18:52'),
|
startTime: new Date('2023-06-16T18:52'),
|
||||||
'clip',
|
cameraID: 'camera-1',
|
||||||
'camera-1',
|
});
|
||||||
);
|
|
||||||
const view = createView({
|
const view = createView({
|
||||||
view: 'media',
|
view: 'media',
|
||||||
camera: 'camera-1',
|
camera: 'camera-1',
|
||||||
queryResults: new MediaQueriesResults([media], 0),
|
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg');
|
expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get title for media viewer view without id', () => {
|
it('should get title for media viewer view without id', () => {
|
||||||
const media = new TestViewMedia(
|
const media = new TestViewMedia({
|
||||||
null,
|
id: null,
|
||||||
new Date('2023-06-16T18:52'),
|
startTime: new Date('2023-06-16T18:52'),
|
||||||
'clip',
|
cameraID: 'camera-1',
|
||||||
'camera-1',
|
});
|
||||||
);
|
|
||||||
const view = createView({
|
const view = createView({
|
||||||
view: 'media',
|
view: 'media',
|
||||||
camera: 'camera-1',
|
camera: 'camera-1',
|
||||||
queryResults: new MediaQueriesResults([media], 0),
|
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg');
|
expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg');
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ViewMediaType } from '../../src/view/media';
|
||||||
|
import { ViewMediaClassifier } from '../../src/view/media-classifier';
|
||||||
|
import { TestViewMedia } from '../test-utils';
|
||||||
|
|
||||||
|
describe('ViewMediaClassifier', () => {
|
||||||
|
describe('isEvent', () => {
|
||||||
|
it.each([
|
||||||
|
['clip' as const, true],
|
||||||
|
['snapshot' as const, true],
|
||||||
|
['recording' as const, false],
|
||||||
|
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
|
||||||
|
expect(
|
||||||
|
ViewMediaClassifier.isEvent(new TestViewMedia({ mediaType: mediaType })),
|
||||||
|
).toBe(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRecording', () => {
|
||||||
|
it.each([
|
||||||
|
['clip' as const, false],
|
||||||
|
['snapshot' as const, false],
|
||||||
|
['recording' as const, true],
|
||||||
|
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
|
||||||
|
expect(
|
||||||
|
ViewMediaClassifier.isRecording(new TestViewMedia({ mediaType: mediaType })),
|
||||||
|
).toBe(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isClip', () => {
|
||||||
|
it.each([
|
||||||
|
['clip' as const, true],
|
||||||
|
['snapshot' as const, false],
|
||||||
|
['recording' as const, false],
|
||||||
|
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
|
||||||
|
expect(
|
||||||
|
ViewMediaClassifier.isClip(new TestViewMedia({ mediaType: mediaType })),
|
||||||
|
).toBe(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSnapshot', () => {
|
||||||
|
it.each([
|
||||||
|
['clip' as const, false],
|
||||||
|
['snapshot' as const, true],
|
||||||
|
['recording' as const, false],
|
||||||
|
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
|
||||||
|
expect(
|
||||||
|
ViewMediaClassifier.isSnapshot(new TestViewMedia({ mediaType: mediaType })),
|
||||||
|
).toBe(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isVideo', () => {
|
||||||
|
it.each([
|
||||||
|
['clip' as const, true],
|
||||||
|
['snapshot' as const, false],
|
||||||
|
['recording' as const, true],
|
||||||
|
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
|
||||||
|
expect(
|
||||||
|
ViewMediaClassifier.isVideo(new TestViewMedia({ mediaType: mediaType })),
|
||||||
|
).toBe(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ViewMedia } from '../../src/view/media';
|
||||||
|
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||||
|
import { generateViewMediaArray } from '../test-utils';
|
||||||
|
|
||||||
|
describe('dispatchViewContextChangeEvent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should function with empty results', () => {
|
||||||
|
const fakeNow = new Date('2023-08-07T20:44');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(fakeNow);
|
||||||
|
|
||||||
|
const results = new MediaQueriesResults();
|
||||||
|
expect(results.isSupersetOf(results)).toBeFalsy();
|
||||||
|
expect(results.getCameraIDs()).toEqual(new Set());
|
||||||
|
expect(results.getResults()).toEqual([]);
|
||||||
|
expect(results.getResultsCount()).toEqual(0);
|
||||||
|
expect(results.hasResults()).toBeFalsy();
|
||||||
|
expect(results.getResult(0)).toBeNull();
|
||||||
|
expect(results.getSelectedIndex()).toBeNull();
|
||||||
|
expect(results.getSelectedResult()).toBeNull();
|
||||||
|
expect(results.hasSelectedResult()).toBeFalsy();
|
||||||
|
|
||||||
|
expect(results.resetSelectedResult()).toBe(results);
|
||||||
|
expect(results.getResultsTimestamp()).toEqual(fakeNow);
|
||||||
|
|
||||||
|
expect(results.selectIndex(0)).toEqual(results);
|
||||||
|
expect(results.getSelectedResult()).toBeNull();
|
||||||
|
|
||||||
|
expect(results.selectResultIfFound((_media: ViewMedia) => true)).toEqual(results);
|
||||||
|
expect(results.getSelectedResult()).toBeNull();
|
||||||
|
|
||||||
|
expect(results.selectBestResult((_media: ViewMedia[]) => null)).toEqual(results);
|
||||||
|
expect(results.getSelectedResult()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should function with basic results', () => {
|
||||||
|
const testResults = generateViewMediaArray();
|
||||||
|
const results = new MediaQueriesResults({ results: testResults });
|
||||||
|
|
||||||
|
expect(results.isSupersetOf(results)).toBeTruthy();
|
||||||
|
expect(results.getCameraIDs()).toEqual(new Set(['kitchen', 'office']));
|
||||||
|
expect(results.getResults()).toEqual(testResults);
|
||||||
|
expect(results.getResultsCount()).toEqual(200);
|
||||||
|
expect(results.hasResults()).toBeTruthy();
|
||||||
|
expect(results.getResult(0)).not.toBeNull();
|
||||||
|
expect(results.getSelectedIndex()).toBe(199);
|
||||||
|
expect(results.getSelectedResult()).not.toBeNull();
|
||||||
|
expect(results.hasSelectedResult()).toBeTruthy();
|
||||||
|
|
||||||
|
expect(results.resetSelectedResult()).toBe(results);
|
||||||
|
expect(results.getSelectedResult()).toBeNull();
|
||||||
|
|
||||||
|
expect(results.selectIndex(100)).toEqual(results);
|
||||||
|
expect(results.getSelectedIndex()).toBe(100);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
results.selectResultIfFound(
|
||||||
|
(media: ViewMedia) => media.getID() === 'id-kitchen-42',
|
||||||
|
),
|
||||||
|
).toEqual(results);
|
||||||
|
expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-42');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
results.selectBestResult((mediaArray: ViewMedia[]) =>
|
||||||
|
mediaArray.findIndex((media) => media.getID() === 'id-kitchen-43'),
|
||||||
|
),
|
||||||
|
).toEqual(results);
|
||||||
|
expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-43');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should function with camera slice', () => {
|
||||||
|
const testResults = generateViewMediaArray();
|
||||||
|
const results = new MediaQueriesResults({ results: testResults });
|
||||||
|
const slice = results.getSlice('office');
|
||||||
|
expect(slice).not.toBeNull();
|
||||||
|
expect(slice!.getResults()).toEqual(
|
||||||
|
testResults.filter((media) => media.getCameraID() === 'office'),
|
||||||
|
);
|
||||||
|
expect(slice!.getResultsCount()).toEqual(100);
|
||||||
|
expect(slice!.hasResults()).toBeTruthy();
|
||||||
|
expect(slice!.getResult(0)).not.toBeNull();
|
||||||
|
expect(slice!.getResult()).toBeNull();
|
||||||
|
expect(slice!.getSelectedIndex()).toBe(99);
|
||||||
|
expect(slice!.getSelectedResult()?.getID()).toEqual('id-office-99');
|
||||||
|
expect(slice!.hasSelectedResult()).toBeTruthy();
|
||||||
|
|
||||||
|
expect(slice!.resetSelectedResult());
|
||||||
|
expect(slice!.getSelectedResult()).toBeNull();
|
||||||
|
|
||||||
|
expect(slice!.selectIndex(10));
|
||||||
|
expect(slice!.getSelectedIndex()).toBe(10);
|
||||||
|
|
||||||
|
expect(slice!.selectIndex(10000));
|
||||||
|
expect(slice!.getSelectedIndex()).toBe(10);
|
||||||
|
|
||||||
|
expect(slice!.selectIndex(-10000));
|
||||||
|
expect(slice!.getSelectedIndex()).toBe(10);
|
||||||
|
|
||||||
|
slice!.selectResultIfFound((media: ViewMedia) => media.getID() === 'id-office-42');
|
||||||
|
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-42');
|
||||||
|
|
||||||
|
slice!.selectBestResult((mediaArray: ViewMedia[]) =>
|
||||||
|
mediaArray.findIndex((media) => media.getID() === 'id-office-43'),
|
||||||
|
);
|
||||||
|
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-43');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respect select approach during construction', () => {
|
||||||
|
it.each([
|
||||||
|
['first' as const, 0],
|
||||||
|
['last' as const, 199],
|
||||||
|
])('%s', async (selectApproach, expectedIndex) => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
selectApproach: selectApproach,
|
||||||
|
});
|
||||||
|
expect(results.getSelectedIndex()).toBe(expectedIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect selectIndex during construction', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
selectedIndex: 42,
|
||||||
|
});
|
||||||
|
expect(results.getSelectedIndex()).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly clone a slice', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
const slice = results.getSlice('office');
|
||||||
|
const clone = slice?.clone();
|
||||||
|
expect(clone?.getResults()).toBe(slice?.getResults());
|
||||||
|
expect(clone?.getSelectedIndex()).toBe(slice?.getSelectedIndex());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not get slice for non-existent camera', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
expect(results.getSlice('not-a-camera')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get main slice', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
expect(results.getSlice()?.getResults()).toBe(results.getResults());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly clone', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
const clone = results.clone();
|
||||||
|
expect(results.getResultsTimestamp()).toBe(clone.getResultsTimestamp());
|
||||||
|
expect(results.getResults()).toBe(clone.getResults());
|
||||||
|
for (const cameraID of results.getCameraIDs()) {
|
||||||
|
expect(results.getSlice(cameraID)?.getResults()).toBe(
|
||||||
|
clone.getSlice(cameraID)?.getResults(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not getResults on invalid slice', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
expect(results.getResults('not-a-camera')).toBeNull();
|
||||||
|
expect(results.getResultsCount('not-a-camera')).toBe(0);
|
||||||
|
expect(results.hasSelectedResult('not-a-camera')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always demote main selection', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
|
||||||
|
results
|
||||||
|
.getSlice('office')
|
||||||
|
?.selectResultIfFound((media) => media.getID() === 'id-office-42');
|
||||||
|
|
||||||
|
// Verify main and office selections are as expected.
|
||||||
|
expect(results.getSelectedIndex()).toBe(199);
|
||||||
|
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42');
|
||||||
|
|
||||||
|
// Select a different main result...
|
||||||
|
results?.selectResultIfFound((media) => media.getID() === 'id-office-80');
|
||||||
|
|
||||||
|
// ... and ensure that selection has been demoted into the camera slice.
|
||||||
|
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-80');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should promote camera selection', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
|
||||||
|
results
|
||||||
|
.getSlice('office')
|
||||||
|
?.selectResultIfFound((media) => media.getID() === 'id-office-42');
|
||||||
|
|
||||||
|
expect(results.getSelectedIndex()).toBe(199);
|
||||||
|
|
||||||
|
results.promoteCameraSelectionToMainSelection('office');
|
||||||
|
|
||||||
|
expect(results.getSelectedIndex()).not.toBe(199);
|
||||||
|
expect(results.getSelectedResult()?.getID()).toBe('id-office-42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should selectBestResult via advanced selection criteria', () => {
|
||||||
|
const results = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray(),
|
||||||
|
});
|
||||||
|
|
||||||
|
results.selectBestResult(
|
||||||
|
(mediaArray: ViewMedia[]) => {
|
||||||
|
const index = mediaArray.findIndex((media) => media.getID()?.endsWith('-42'));
|
||||||
|
return index < 0 ? null : index;
|
||||||
|
},
|
||||||
|
{ allCameras: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42');
|
||||||
|
expect(results.getSelectedResult('kitchen')?.getID()).toBe('id-kitchen-42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
EventQuery,
|
||||||
|
PartialEventQuery,
|
||||||
|
PartialRecordingQuery,
|
||||||
|
QueryType,
|
||||||
|
RecordingQuery,
|
||||||
|
} from '../../src/camera-manager/types';
|
||||||
|
import { setify } from '../../src/utils/basic';
|
||||||
|
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
||||||
|
|
||||||
|
describe('EventMediaQueries', () => {
|
||||||
|
const createRawEventQueries = (
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
query?: PartialEventQuery,
|
||||||
|
): EventQuery[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: QueryType.Event,
|
||||||
|
cameraIDs: setify(cameraIDs),
|
||||||
|
...query,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should construct', () => {
|
||||||
|
const rawQueries = createRawEventQueries('office');
|
||||||
|
const queries = new EventMediaQueries(rawQueries);
|
||||||
|
expect(queries.getQueries()).toBe(rawQueries);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set', () => {
|
||||||
|
const rawQueries = createRawEventQueries('office');
|
||||||
|
const queries = new EventMediaQueries(rawQueries);
|
||||||
|
|
||||||
|
const newRawQueries = createRawEventQueries('kitchen');
|
||||||
|
queries.setQueries(newRawQueries);
|
||||||
|
expect(queries.getQueries()).toBe(newRawQueries);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should determine if queries exist for CameraIDs', () => {
|
||||||
|
const rawQueries = createRawEventQueries(new Set(['office', 'kitchen']));
|
||||||
|
const queries = new EventMediaQueries(rawQueries);
|
||||||
|
|
||||||
|
expect(queries.hasQueriesForCameraIDs(new Set(['office']))).toBeTruthy();
|
||||||
|
expect(queries.hasQueriesForCameraIDs(new Set(['office', 'kitchen']))).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
queries.hasQueriesForCameraIDs(new Set(['office', 'front_door'])),
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert to clips querys', () => {
|
||||||
|
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
|
||||||
|
const queries = new EventMediaQueries(rawQueries);
|
||||||
|
|
||||||
|
expect(queries.convertToClipsQueries().getQueries()).toEqual([
|
||||||
|
{
|
||||||
|
type: QueryType.Event,
|
||||||
|
cameraIDs: new Set(['office']),
|
||||||
|
hasClip: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert when queries are null', () => {
|
||||||
|
const queries = new EventMediaQueries();
|
||||||
|
expect(queries.convertToClipsQueries().getQueries()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clone', () => {
|
||||||
|
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
|
||||||
|
const queries = new EventMediaQueries(rawQueries);
|
||||||
|
expect(queries.clone().getQueries()).toEqual(queries.getQueries());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecordingMediaQueries', () => {
|
||||||
|
const createRawRecordingQueries = (
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
query?: PartialRecordingQuery,
|
||||||
|
): RecordingQuery[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: QueryType.Recording,
|
||||||
|
cameraIDs: setify(cameraIDs),
|
||||||
|
...query,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should construct', () => {
|
||||||
|
const rawQueries = createRawRecordingQueries('office');
|
||||||
|
const queries = new RecordingMediaQueries(rawQueries);
|
||||||
|
expect(queries.getQueries()).toBe(rawQueries);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clone', () => {
|
||||||
|
const rawQueries = createRawRecordingQueries('office');
|
||||||
|
const queries = new RecordingMediaQueries(rawQueries);
|
||||||
|
expect(queries.clone().getQueries()).toEqual(queries.getQueries());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { VideoContentType, ViewMedia } from '../../src/view/media';
|
||||||
|
import { TestViewMedia } from '../test-utils';
|
||||||
|
|
||||||
|
describe('ViewMedia', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct', () => {
|
||||||
|
const media = new ViewMedia('clip', 'camera');
|
||||||
|
expect(media.getCameraID()).toBe('camera');
|
||||||
|
expect(media.getMediaType()).toBe('clip');
|
||||||
|
expect(media.getVideoContentType()).toBeNull();
|
||||||
|
expect(media.getID()).toBeNull();
|
||||||
|
expect(media.getStartTime()).toBeNull();
|
||||||
|
expect(media.getEndTime()).toBeNull();
|
||||||
|
expect(media.getUsableEndTime()).toBeNull();
|
||||||
|
expect(media.inProgress()).toBeNull();
|
||||||
|
expect(media.getContentID()).toBeNull();
|
||||||
|
expect(media.getTitle()).toBeNull();
|
||||||
|
expect(media.getThumbnail()).toBeNull();
|
||||||
|
expect(media.getTitle()).toBeNull();
|
||||||
|
expect(media.includesTime(new Date())).toBeFalsy();
|
||||||
|
expect(media.getWhere()).toBeNull();
|
||||||
|
expect(media.setFavorite(true)).toBeUndefined();
|
||||||
|
expect(media.isFavorite()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly determine if a media item includes a time', () => {
|
||||||
|
const media = new TestViewMedia({
|
||||||
|
startTime: new Date('2023-08-08T17:00:00'),
|
||||||
|
endTime: new Date('2023-08-08T17:59:59'),
|
||||||
|
});
|
||||||
|
expect(media.includesTime(new Date('2023-08-08T17:30:30'))).toBeTruthy();
|
||||||
|
expect(media.includesTime(new Date('2023-08-08T18:00:00'))).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly get usable end time for in-progress event', () => {
|
||||||
|
const media = new TestViewMedia({
|
||||||
|
startTime: new Date('2023-08-08T17:00:00'),
|
||||||
|
inProgress: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fakeNow = new Date('2023-08-08T17:15:00');
|
||||||
|
vi.setSystemTime(fakeNow);
|
||||||
|
|
||||||
|
expect(media.getUsableEndTime()).toEqual(fakeNow)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VideoContentType', () => {
|
||||||
|
it('MP4', () => {
|
||||||
|
expect(VideoContentType.MP4).toBe('mp4');
|
||||||
|
});
|
||||||
|
it('HLS', () => {
|
||||||
|
expect(VideoContentType.HLS).toBe('hls');
|
||||||
|
});
|
||||||
|
});
|
||||||
+87
-7
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it, test, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { QueryType } from '../../src/camera-manager/types';
|
import { QueryType } from '../../src/camera-manager/types';
|
||||||
import { ViewMedia } from '../../src/view/media';
|
import { ViewMedia } from '../../src/view/media';
|
||||||
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
||||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||||
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
|
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
|
||||||
import { createView } from '../test-utils';
|
import { createView, generateViewMediaArray } from '../test-utils';
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('View Basics', () => {
|
describe('View Basics', () => {
|
||||||
@@ -52,6 +52,7 @@ describe('View Basics', () => {
|
|||||||
query: new EventMediaQueries(),
|
query: new EventMediaQueries(),
|
||||||
queryResults: new MediaQueriesResults(),
|
queryResults: new MediaQueriesResults(),
|
||||||
context: {},
|
context: {},
|
||||||
|
displayMode: 'single',
|
||||||
});
|
});
|
||||||
|
|
||||||
const evolved = view.evolve({
|
const evolved = view.evolve({
|
||||||
@@ -60,12 +61,14 @@ describe('View Basics', () => {
|
|||||||
query: new EventMediaQueries(),
|
query: new EventMediaQueries(),
|
||||||
queryResults: new MediaQueriesResults(),
|
queryResults: new MediaQueriesResults(),
|
||||||
context: {},
|
context: {},
|
||||||
|
displayMode: 'grid',
|
||||||
});
|
});
|
||||||
expect(evolved.view).not.toBe(view.view);
|
expect(evolved.view).not.toBe(view.view);
|
||||||
expect(evolved.camera).not.toBe(view.camera);
|
expect(evolved.camera).not.toBe(view.camera);
|
||||||
expect(evolved.query).not.toBe(view.query);
|
expect(evolved.query).not.toBe(view.query);
|
||||||
expect(evolved.queryResults).not.toBe(view.queryResults);
|
expect(evolved.queryResults).not.toBe(view.queryResults);
|
||||||
expect(evolved.context).not.toBe(view.context);
|
expect(evolved.context).not.toBe(view.context);
|
||||||
|
expect(evolved.displayMode).not.toBe(view.displayMode);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should evolve with nothing set', () => {
|
it('should evolve with nothing set', () => {
|
||||||
@@ -119,6 +122,13 @@ describe('View Basics', () => {
|
|||||||
expect(view.context).toEqual({});
|
expect(view.context).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should remove context property', () => {
|
||||||
|
const view = createView({ context: { live: { overrides: new Map() } } });
|
||||||
|
|
||||||
|
view.removeContextProperty('live', 'overrides');
|
||||||
|
expect(view.context).toEqual({ live: {} });
|
||||||
|
});
|
||||||
|
|
||||||
it('should detect gallery views', () => {
|
it('should detect gallery views', () => {
|
||||||
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
|
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
|
||||||
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
|
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
|
||||||
@@ -240,8 +250,8 @@ describe('View.isMajorMediaChange', () => {
|
|||||||
|
|
||||||
it('should consider result change as major in other view', () => {
|
it('should consider result change as major in other view', () => {
|
||||||
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
|
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
|
||||||
const queryResults_1 = new MediaQueriesResults(media, 0);
|
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
|
||||||
const queryResults_2 = new MediaQueriesResults(media, 1);
|
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
|
||||||
expect(
|
expect(
|
||||||
View.isMajorMediaChange(
|
View.isMajorMediaChange(
|
||||||
createView({ view: 'media', queryResults: queryResults_1 }),
|
createView({ view: 'media', queryResults: queryResults_1 }),
|
||||||
@@ -252,8 +262,8 @@ describe('View.isMajorMediaChange', () => {
|
|||||||
|
|
||||||
it('should not consider selected result change as major in live view', () => {
|
it('should not consider selected result change as major in live view', () => {
|
||||||
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
|
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
|
||||||
const queryResults_1 = new MediaQueriesResults(media, 0);
|
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
|
||||||
const queryResults_2 = new MediaQueriesResults(media, 1);
|
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
|
||||||
expect(
|
expect(
|
||||||
View.isMajorMediaChange(
|
View.isMajorMediaChange(
|
||||||
createView({ queryResults: queryResults_1 }),
|
createView({ queryResults: queryResults_1 }),
|
||||||
@@ -282,7 +292,7 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
|||||||
expect(next.queryResults).toBe(queryResults);
|
expect(next.queryResults).toBe(queryResults);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
it.each([
|
||||||
[
|
[
|
||||||
new EventMediaQueries([
|
new EventMediaQueries([
|
||||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||||
@@ -409,6 +419,76 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
|||||||
View.adoptFromViewIfAppropriate(next, current);
|
View.adoptFromViewIfAppropriate(next, current);
|
||||||
expect(next.context?.live).toEqual(current.context?.live);
|
expect(next.context?.live).toEqual(current.context?.live);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should determine if display mode is grid', () => {
|
||||||
|
expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy();
|
||||||
|
expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy();
|
||||||
|
expect(createView().isGrid()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should determine if view supports multiple display modes', () => {
|
||||||
|
const resultsOne = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray({ cameraIDs: ['office'] }),
|
||||||
|
});
|
||||||
|
const resultsTwo = new MediaQueriesResults({
|
||||||
|
results: generateViewMediaArray({ cameraIDs: ['office', 'kitchen'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createView({ view: 'live' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(createView({ view: 'live' }).hasMultipleDisplayModes(0)).toBeFalsy();
|
||||||
|
expect(createView({ view: 'live' }).hasMultipleDisplayModes(1)).toBeFalsy();
|
||||||
|
expect(createView({ view: 'live' }).hasMultipleDisplayModes(2)).toBeTruthy();
|
||||||
|
|
||||||
|
expect(createView({ view: 'media' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({ view: 'media', queryResults: resultsOne }).hasMultipleDisplayModes(),
|
||||||
|
).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({ view: 'media', queryResults: resultsTwo }).hasMultipleDisplayModes(),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
expect(createView({ view: 'clip' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({ view: 'clip', queryResults: resultsOne }).hasMultipleDisplayModes(),
|
||||||
|
).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({ view: 'clip', queryResults: resultsTwo }).hasMultipleDisplayModes(),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
expect(createView({ view: 'snapshot' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({
|
||||||
|
view: 'snapshot',
|
||||||
|
queryResults: resultsOne,
|
||||||
|
}).hasMultipleDisplayModes(),
|
||||||
|
).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({
|
||||||
|
view: 'snapshot',
|
||||||
|
queryResults: resultsTwo,
|
||||||
|
}).hasMultipleDisplayModes(),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
expect(createView({ view: 'recording' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({
|
||||||
|
view: 'recording',
|
||||||
|
queryResults: resultsOne,
|
||||||
|
}).hasMultipleDisplayModes(),
|
||||||
|
).toBeFalsy();
|
||||||
|
expect(
|
||||||
|
createView({
|
||||||
|
view: 'recording',
|
||||||
|
queryResults: resultsTwo,
|
||||||
|
}).hasMultipleDisplayModes(),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
expect(createView({ view: 'clips' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(createView({ view: 'snapshots' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(createView({ view: 'recordings' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(createView({ view: 'image' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
expect(createView({ view: 'timeline' }).hasMultipleDisplayModes()).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|||||||
@@ -1052,6 +1052,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/jquery@npm:*":
|
||||||
|
version: 3.5.16
|
||||||
|
resolution: "@types/jquery@npm:3.5.16"
|
||||||
|
dependencies:
|
||||||
|
"@types/sizzle": "*"
|
||||||
|
checksum: 13c995f15d1c2f1d322103dc1cb0a22b95eecc3e7546f00279b8731aea21d7ec04550af40e609ee48e755d4e11bf61c25b4aa9f53df3bcbec4b8fe8e81471732
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/json-schema@npm:^7.0.9":
|
"@types/json-schema@npm:^7.0.9":
|
||||||
version: 7.0.11
|
version: 7.0.11
|
||||||
resolution: "@types/json-schema@npm:7.0.11"
|
resolution: "@types/json-schema@npm:7.0.11"
|
||||||
@@ -1082,6 +1091,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/masonry-layout@npm:^4.2.5":
|
||||||
|
version: 4.2.5
|
||||||
|
resolution: "@types/masonry-layout@npm:4.2.5"
|
||||||
|
dependencies:
|
||||||
|
"@types/jquery": "*"
|
||||||
|
checksum: 68961e4dd393a8d51e6e80676a814a6741c0c9175501a1a556ec6d4ef5438aa1e982248a90c7f00099fb18da9529e9f6733be5ffebdb9b2e95cd36a6adeecb40
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/node@npm:*":
|
"@types/node@npm:*":
|
||||||
version: 18.11.19
|
version: 18.11.19
|
||||||
resolution: "@types/node@npm:18.11.19"
|
resolution: "@types/node@npm:18.11.19"
|
||||||
@@ -1112,6 +1130,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/sizzle@npm:*":
|
||||||
|
version: 2.3.3
|
||||||
|
resolution: "@types/sizzle@npm:2.3.3"
|
||||||
|
checksum: 586a9fb1f6ff3e325e0f2cc1596a460615f0bc8a28f6e276ac9b509401039dd242fa8b34496d3a30c52f5b495873922d09a9e76c50c2ab2bcc70ba3fb9c4e160
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/trusted-types@npm:^2.0.2":
|
"@types/trusted-types@npm:^2.0.2":
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
resolution: "@types/trusted-types@npm:2.0.2"
|
resolution: "@types/trusted-types@npm:2.0.2"
|
||||||
@@ -2238,6 +2263,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"desandro-matches-selector@npm:^2.0.0":
|
||||||
|
version: 2.0.2
|
||||||
|
resolution: "desandro-matches-selector@npm:2.0.2"
|
||||||
|
checksum: 30979e6b45d7720d259d4db11ec026fcbee242deee5ec0944c265d7e386a7358ab29f7417cbc3631830d0dbe6e827940dee31f789ed865dba22880d209fc9f8e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"diff@npm:^5.1.0":
|
"diff@npm:^5.1.0":
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
resolution: "diff@npm:5.1.0"
|
resolution: "diff@npm:5.1.0"
|
||||||
@@ -2864,6 +2896,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"ev-emitter@npm:^1.0.0":
|
||||||
|
version: 1.1.1
|
||||||
|
resolution: "ev-emitter@npm:1.1.1"
|
||||||
|
checksum: 3dd78a7620701ef8095794f611dd251bdd9023badf322b3d6fa5e10a660c50e83eec5ee5bbc7654682635cf7b8858c94e2c77e1d10bdc188a2b0593436403476
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"eventemitter3@npm:^4.0.4":
|
"eventemitter3@npm:^4.0.4":
|
||||||
version: 4.0.7
|
version: 4.0.7
|
||||||
resolution: "eventemitter3@npm:4.0.7"
|
resolution: "eventemitter3@npm:4.0.7"
|
||||||
@@ -2977,6 +3016,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"fizzy-ui-utils@npm:^2.0.0":
|
||||||
|
version: 2.0.7
|
||||||
|
resolution: "fizzy-ui-utils@npm:2.0.7"
|
||||||
|
dependencies:
|
||||||
|
desandro-matches-selector: ^2.0.0
|
||||||
|
checksum: 001e54effa22c4f62728b90a25e8eb5477057ab53d0184e1c3231bbdfc7c34e278418fbcaf7d63c75ede823d82e61245e668ce8cba90e1c5699de960188c654f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"flat-cache@npm:^3.0.4":
|
"flat-cache@npm:^3.0.4":
|
||||||
version: 3.0.4
|
version: 3.0.4
|
||||||
resolution: "flat-cache@npm:3.0.4"
|
resolution: "flat-cache@npm:3.0.4"
|
||||||
@@ -3052,6 +3100,7 @@ __metadata:
|
|||||||
"@rollup/plugin-replace": ^4.0.0
|
"@rollup/plugin-replace": ^4.0.0
|
||||||
"@types/bluebird": ^3.5.36
|
"@types/bluebird": ^3.5.36
|
||||||
"@types/lodash-es": ^4.17.5
|
"@types/lodash-es": ^4.17.5
|
||||||
|
"@types/masonry-layout": ^4.2.5
|
||||||
"@typescript-eslint/eslint-plugin": ^5.36.2
|
"@typescript-eslint/eslint-plugin": ^5.36.2
|
||||||
"@typescript-eslint/parser": ^5.36.2
|
"@typescript-eslint/parser": ^5.36.2
|
||||||
"@vitest/coverage-c8": ^0.29.8
|
"@vitest/coverage-c8": ^0.29.8
|
||||||
@@ -3073,6 +3122,7 @@ __metadata:
|
|||||||
lit: ^2.3.1
|
lit: ^2.3.1
|
||||||
lit-flatpickr: ^0.4.0
|
lit-flatpickr: ^0.4.0
|
||||||
lodash-es: ^4.17.21
|
lodash-es: ^4.17.21
|
||||||
|
masonry-layout: ^4.2.2
|
||||||
moment: ^2.29.4
|
moment: ^2.29.4
|
||||||
prettier: ^2.6.0
|
prettier: ^2.6.0
|
||||||
propagating-hammerjs: ^2.0.1
|
propagating-hammerjs: ^2.0.1
|
||||||
@@ -3221,6 +3271,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"get-size@npm:^2.0.2":
|
||||||
|
version: 2.0.3
|
||||||
|
resolution: "get-size@npm:2.0.3"
|
||||||
|
checksum: 18d5a5fdb3f541db8b1e6ad46a5411d4bb7da3061d2ef7e544db3bbce7a13543f05c91fd40c7f68b4bc4bc035c160fed23c45514cde7d5fb18de1cce10208b19
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"get-symbol-description@npm:^1.0.0":
|
"get-symbol-description@npm:^1.0.0":
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
resolution: "get-symbol-description@npm:1.0.0"
|
resolution: "get-symbol-description@npm:1.0.0"
|
||||||
@@ -4229,6 +4286,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"masonry-layout@npm:^4.2.2":
|
||||||
|
version: 4.2.2
|
||||||
|
resolution: "masonry-layout@npm:4.2.2"
|
||||||
|
dependencies:
|
||||||
|
get-size: ^2.0.2
|
||||||
|
outlayer: ^2.1.0
|
||||||
|
checksum: b947029b8fdfb384d17d7749d80a4f00f88d885acea04d77e1fefd36827cc2a345fd40d9ccddf720ed9323e133d8ea498125eece91d2c8517f69fd4e9e1bbdf4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"mdn-data@npm:2.0.14":
|
"mdn-data@npm:2.0.14":
|
||||||
version: 2.0.14
|
version: 2.0.14
|
||||||
resolution: "mdn-data@npm:2.0.14"
|
resolution: "mdn-data@npm:2.0.14"
|
||||||
@@ -4653,6 +4720,17 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"outlayer@npm:^2.1.0":
|
||||||
|
version: 2.1.1
|
||||||
|
resolution: "outlayer@npm:2.1.1"
|
||||||
|
dependencies:
|
||||||
|
ev-emitter: ^1.0.0
|
||||||
|
fizzy-ui-utils: ^2.0.0
|
||||||
|
get-size: ^2.0.2
|
||||||
|
checksum: a8b69d07bad6b498ea1956f89a4bae6d38414d81a5feeae6af06cd8206a1bf47db82f664dc3ccd13b8d2c5886a6cded4a4642d0fafcbbd80f68873b7eb9f4153
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"p-finally@npm:^1.0.0":
|
"p-finally@npm:^1.0.0":
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
resolution: "p-finally@npm:1.0.0"
|
resolution: "p-finally@npm:1.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user