Initial support for grid for live and media viewer.

This commit is contained in:
Dermot Duffy
2023-08-08 22:26:25 -07:00
parent 69249b6c33
commit b663e0b731
59 changed files with 3308 additions and 429 deletions
+66 -8
View File
@@ -59,6 +59,7 @@ import {
Message,
MESSAGE_TYPE_PRIORITIES,
RawFrigateCardConfig,
ViewDisplayMode,
} from './types.js';
import {
convertActionToFrigateCardCustomAction,
@@ -97,6 +98,7 @@ import {
} from './utils/substream';
import { Timer } from './utils/timer';
import { getParseErrorPaths } from './utils/zod.js';
import { ViewMediaClassifier } from './view/media-classifier';
import { View } from './view/view.js';
/** A note on media callbacks:
@@ -186,6 +188,9 @@ class FrigateCard extends LitElement {
@state()
protected _expand = false;
@state()
protected _viewDisplayMode?: ViewDisplayMode;
protected _microphoneController?: MicrophoneController;
protected _conditionController?: ConditionController;
protected _automationsController?: AutomationsController;
@@ -414,6 +419,7 @@ class FrigateCard extends LitElement {
state: this._hass.states,
}),
media_loaded: this._mediaLoadedInfoController.has(),
displayMode: undefined,
});
}
@@ -466,6 +472,7 @@ class FrigateCard extends LitElement {
this._conditionController?.setState({
view: this._view.view,
camera: this._view.camera,
displayMode: this._view.displayMode ?? undefined,
});
};
@@ -493,10 +500,14 @@ class FrigateCard extends LitElement {
}
if (cameraID) {
const viewName = args?.viewName ?? this._getConfig().view.default;
const displayMode =
this._viewDisplayMode ?? this._getDefaultDisplayModeForView(viewName);
changeView(
new View({
view: args?.viewName ?? this._getConfig().view.default,
view: viewName,
camera: cameraID,
displayMode: displayMode,
}),
);
@@ -641,7 +652,10 @@ class FrigateCard extends LitElement {
targetCamera &&
(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;
}
}
@@ -1003,7 +1017,7 @@ class FrigateCard extends LitElement {
if (this._view.isViewerView() && media) {
media_content_id = media.getContentID();
media_content_type = media.getContentType();
media_content_type = ViewMediaClassifier.isVideo(media) ? 'video' : 'image';
title = media.getTitle();
thumbnail = media.getThumbnail();
} 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 {
// 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
@@ -1119,7 +1151,8 @@ class FrigateCard extends LitElement {
? targetView
: FRIGATE_CARD_VIEW_DEFAULT;
this._changeView({
view: new View({ view: actualView, camera: selectCameraID }),
viewName: actualView,
cameraID: selectCameraID,
});
}
break;
@@ -1203,6 +1236,25 @@ class FrigateCard extends LitElement {
}
});
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:
console.warn(`Frigate card received unknown card action: ${action}`);
}
@@ -1501,15 +1553,21 @@ class FrigateCard extends LitElement {
? `${lastKnown.width} / ${lastKnown.height}`
: 'unset',
);
// Non-media mays have no intrinsic dimensions and so we need to explicit
// request the dialog to use all available space.
// Non-media may have no intrinsic dimensions (or multiple media items in a
// grid) and so we need to explicit request the dialog to use all available
// space.
const isGrid = this._view?.isGrid();
this.style.setProperty(
'--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(
'--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)',
);
}
+4 -2
View File
@@ -368,7 +368,9 @@ export class FrigateCardGalleryCore extends LitElement {
this.view
?.evolve({
query: newMediaQueries,
queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
queryResults: new MediaQueriesResults({
results: extension.results,
}).selectResultIfFound(
(media) => media === this.view?.queryResults?.getSelectedResult(),
),
})
@@ -468,7 +470,7 @@ export class FrigateCardGalleryCore extends LitElement {
this.view
.evolve({
view: 'media',
queryResults: this.view.queryResults?.clone().selectResult(
queryResults: this.view.queryResults?.clone().selectIndex(
// Media in the gallery is reversed vs the queryResults (see
// note above).
this._media.length - index - 1,
+3 -3
View File
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.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 '../image.js';
import { getStateObjOrDispatchError } from './live.js';
@@ -50,7 +50,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
}
public async getScreenshotURL(): Promise<string | null> {
return await this._refImage.value?.getScreenshotURL() ?? null;
return (await this._refImage.value?.getScreenshotURL()) ?? null;
}
protected render(): TemplateResult | void {
@@ -77,7 +77,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveImageStyle);
return unsafeCSS(basicBlockStyle);
}
}
+185 -47
View File
@@ -16,12 +16,12 @@ import { guard } from 'lit/directives/guard.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.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 { localize } from '../../localize/localize.js';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveProviderStyle from '../../scss/live-provider.scss';
import liveStyle from '../../scss/live.scss';
import basicBlockStyle from '../../scss/basic-block.scss';
import {
CameraConfig,
CardWideConfig,
@@ -56,6 +56,9 @@ import '../surround.js';
import '../title-control.js';
import { AutoMediaPlugin } from './../embla-plugins/automedia.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 {
// A cameraID override (used for dependencies/substreams to force a different
@@ -126,7 +129,10 @@ export class FrigateCardLive extends LitElement {
public view?: Readonly<View>;
@property({ attribute: false })
public liveConfig?: LiveConfig;
public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
@@ -228,13 +234,18 @@ export class FrigateCardLive extends LitElement {
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) {
if (
!this.hass ||
!this.nonOverriddenLiveConfig ||
!this.cameraManager ||
!this.view
) {
return;
}
// Notes:
// - See use of liveConfig and not config below -- the carousel will
// independently override the liveConfig to reflect the camera in the
// - See use of liveConfig and not config below -- the underlying carousel
// will independently override the liveConfig to reflect the camera in the
// carousel (not necessarily the selected camera).
// - Various events are captured to prevent them propagating upwards if the
// card is in the background.
@@ -244,10 +255,11 @@ export class FrigateCardLive extends LitElement {
const result = html`${keyed(
this._renderKey,
html`
<frigate-card-live-carousel
<frigate-card-live-grid
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.liveConfig}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._inBackground}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.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;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveStyle);
return unsafeCSS(basicBlockStyle);
}
}
@customElement('frigate-card-live-carousel')
export class FrigateCardLiveCarousel extends LitElement {
@customElement('frigate-card-live-grid')
export class FrigateCardLiveGrid extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@@ -301,7 +310,10 @@ export class FrigateCardLiveCarousel extends LitElement {
public view?: Readonly<View>;
@property({ attribute: false })
public liveConfig?: LiveConfig;
public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
@@ -321,6 +333,109 @@ export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false })
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.
protected _cameraToSlide: Record<string, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
@@ -357,7 +472,7 @@ export class FrigateCardLiveCarousel extends LitElement {
*/
protected _getTransitionEffect(): TransitionEffect {
return (
this.liveConfig?.transition_effect ??
this.overriddenLiveConfig?.transition_effect ??
frigateCardConfigDefaults.live.transition_effect
);
}
@@ -376,7 +491,9 @@ export class FrigateCardLiveCarousel extends LitElement {
*/
protected _getOptions(): EmblaOptionsType {
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,
};
}
@@ -386,10 +503,12 @@ export class FrigateCardLiveCarousel extends LitElement {
* @returns A list of EmblaOptionsTypes.
*/
protected _getPlugins(): EmblaCarouselPlugins {
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
const cameraCount = this.viewFilterCameraID
? 1
: this.cameraManager?.getStore().getVisibleCameraCount() ?? 0;
return [
// Only enable wheel plugin if there is more than one camera.
...(cameras && cameras.size > 1
...(cameraCount > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
@@ -399,28 +518,28 @@ export class FrigateCardLiveCarousel extends LitElement {
]
: []),
Lazyload({
...(this.liveConfig?.lazy_load && {
...(this.overriddenLiveConfig?.lazy_load && {
lazyLoadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('load', index, slide),
}),
lazyUnloadCondition: this.liveConfig?.lazy_unload,
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
lazyUnloadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('unload', index, slide),
}),
AutoMediaPlugin({
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
...(this.liveConfig?.auto_play && {
autoPlayCondition: this.liveConfig.auto_play,
...(this.overriddenLiveConfig?.auto_play && {
autoPlayCondition: this.overriddenLiveConfig.auto_play,
}),
...(this.liveConfig?.auto_pause && {
autoPauseCondition: this.liveConfig.auto_pause,
...(this.overriddenLiveConfig?.auto_pause && {
autoPauseCondition: this.overriddenLiveConfig.auto_pause,
}),
...(this.liveConfig?.auto_mute && {
autoMuteCondition: this.liveConfig.auto_mute,
...(this.overriddenLiveConfig?.auto_mute && {
autoMuteCondition: this.overriddenLiveConfig.auto_mute,
}),
...(this.liveConfig?.auto_unmute && {
autoUnmuteCondition: this.liveConfig.auto_unmute,
...(this.overriddenLiveConfig?.auto_unmute && {
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
}),
}),
];
@@ -435,7 +554,7 @@ export class FrigateCardLiveCarousel extends LitElement {
*/
protected _getLazyLoadCount(): number | null {
// 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.
*/
protected _getSlides(): [TemplateResult[], Record<string, number>] {
const visibleCameras = this.cameraManager?.getStore().getVisibleCameras();
if (!visibleCameras) {
let cameras: CameraConfigs | null = null;
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 [[], {}];
}
const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {};
for (const [cameraID, cameraConfig] of visibleCameras) {
for (const [cameraID, cameraConfig] of cameras) {
const liveCameraID =
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
const liveCameraConfig =
@@ -525,7 +654,8 @@ export class FrigateCardLiveCarousel extends LitElement {
slideIndex: number,
): TemplateResult | void {
if (
!this.liveConfig ||
!this.overriddenLiveConfig ||
!this.nonOverriddenLiveConfig ||
!this.hass ||
!this.cameraManager ||
!this.conditionControllerEpoch
@@ -538,7 +668,7 @@ export class FrigateCardLiveCarousel extends LitElement {
// stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig(
this.conditionControllerEpoch.controller,
this.liveConfig,
this.nonOverriddenLiveConfig,
this.liveOverrides,
{ camera: cameraID },
) as LiveConfig;
@@ -548,7 +678,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return html`
<div class="embla__slide">
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
?disabled=${config.lazy_load}
.microphoneStream=${this.view?.camera === cameraID
? this.microphoneStream
: undefined}
@@ -575,11 +705,13 @@ export class FrigateCardLiveCarousel extends LitElement {
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (!cameras || !this.view || !this.hass) {
if (this.viewFilterCameraID || !cameras || !this.view || !this.hass) {
return [null, null];
}
const cameraID = this.viewFilterCameraID ?? this.view.camera;
const keys = Array.from(cameras.keys());
const currentIndex = keys.indexOf(this.view.camera);
const currentIndex = keys.indexOf(cameraID);
if (currentIndex < 0 || cameras.size <= 1) {
return [null, null];
@@ -596,7 +728,7 @@ export class FrigateCardLiveCarousel extends LitElement {
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) {
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
return;
}
@@ -617,12 +749,17 @@ export class FrigateCardLiveCarousel extends LitElement {
: null;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
this.hass,
overrideCameraID(this.view.camera),
overrideCameraID(this.viewFilterCameraID ?? this.view.camera),
);
const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID))
: null;
const titleConfig = getDefaultTitleConfigForView(
this.view,
this.overriddenLiveConfig?.controls.title,
);
// Notes on the below:
// - guard() is used to avoid reseting the carousel unless the
// options/plugins actually change.
@@ -637,18 +774,18 @@ export class FrigateCardLiveCarousel extends LitElement {
<frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${guard(
[this.cameraManager, this.liveConfig],
[this.cameraManager, this.overriddenLiveConfig],
this._getOptions.bind(this),
)}
.carouselPlugins=${guard(
[this.cameraManager, this.liveConfig],
[this.cameraManager, this.overriddenLiveConfig],
this._getPlugins.bind(this),
) as EmblaCarouselPlugins}
.label="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${this.liveConfig.controls.title}
.titlePopupConfig=${titleConfig ?? undefined}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@@ -661,7 +798,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.liveConfig.controls.next_previous}
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
.label=${cameraMetadataPrevious?.title ?? ''}
.icon=${cameraMetadataPrevious?.icon}
?disabled=${prevID === null}
@@ -676,7 +813,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.liveConfig.controls.next_previous}
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
.label=${cameraMetadataNext?.title ?? ''}
.icon=${cameraMetadataNext?.icon}
?disabled=${nextID === null}
@@ -794,7 +931,7 @@ export class FrigateCardLiveProvider
public async getScreenshotURL(): Promise<string | null> {
await this.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 {
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
'frigate-card-live-carousel': FrigateCardLiveCarousel;
'frigate-card-live-grid': FrigateCardLiveGrid;
'frigate-card-live': FrigateCardLive;
}
}
-3
View File
@@ -428,9 +428,6 @@ export class FrigateCardMediaCarousel extends LitElement {
: ``}`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(mediaCarouselStyle);
}
+80
View File
@@ -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;
}
}
+9 -15
View File
@@ -7,7 +7,9 @@ import {
unsafeCSS,
} from 'lit';
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 {
CardWideConfig,
ClipsOrSnapshotsOrAll,
@@ -16,13 +18,11 @@ import {
ThumbnailsControlConfig,
} from '../types.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 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 {
// Whether or not to fetch thumbnails.
@@ -88,6 +88,7 @@ export class FrigateCardSurround extends LitElement {
this.cardWideConfig,
this.view,
{
allCameras: this.view.isGrid(),
targetView: this.view.view,
mediaType: this.fetchMedia,
select: 'latest',
@@ -151,10 +152,6 @@ export class FrigateCardSurround extends LitElement {
return null;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.hass || !this.view) {
return;
@@ -230,11 +227,8 @@ export class FrigateCardSurround extends LitElement {
</frigate-card-surround-basic>`;
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(surroundStyle);
return unsafeCSS(basicBlockStyle);
}
}
+1 -1
View File
@@ -170,7 +170,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
this,
'thumbnail-carousel:tap',
{
queryResults: this.view.queryResults.clone().selectResult(index),
queryResults: this.view.queryResults.clone().selectIndex(index),
},
);
}
+41 -30
View File
@@ -485,7 +485,10 @@ export class FrigateCardTimelineCore extends LitElement {
: results
.clone()
.resetSelectedResult()
.selectBestResult((media) => findBestMediaIndex(media, targetTime));
.selectBestResult((media) => findBestMediaIndex(media, targetTime), {
allCameras: true,
main: true,
});
const desiredView: FrigateCardView = this.mini
? targetTime >= new Date()
@@ -496,8 +499,7 @@ export class FrigateCardTimelineCore extends LitElement {
this.view
.evolve({
view: desiredView,
...(newResults &&
newResults.hasSelectedResult() && { queryResults: newResults }),
queryResults: newResults,
}) // Whether or not to set the timeline window.
.mergeInContext({
...(canSeek && { mediaViewer: { seek: targetTime } }),
@@ -597,10 +599,15 @@ export class FrigateCardTimelineCore extends LitElement {
);
}
} 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
?.clone()
.resetSelectedResult()
.selectResultIfFound((media) => media.getID() === properties.item);
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
if (!newResults || !newResults.hasSelectedResult()) {
// This can happen in a few situations:
@@ -788,11 +795,12 @@ export class FrigateCardTimelineCore extends LitElement {
return new DataSet(groups);
}
protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null {
const startTime = media.getStartTime();
const endTime = media.getEndTime();
if (ViewMediaClassifier.isEvent(media)) {
protected _getPerfectWindowFromMediaStartAndEndTime(
isEvent: boolean,
startTime: Date | null,
endTime: Date | null,
): TimelineWindow | null {
if (isEvent) {
const windowSeconds = this._getConfiguredWindowSeconds();
if (startTime && endTime) {
@@ -820,7 +828,7 @@ export class FrigateCardTimelineCore extends LitElement {
end: add(startTime, { seconds: windowSeconds / 2 }),
};
}
} else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) {
} else if (startTime && endTime) {
return {
start: startTime,
end: endTime,
@@ -980,8 +988,10 @@ export class FrigateCardTimelineCore extends LitElement {
let desiredWindow = timelineWindow;
const media = this.view.queryResults?.getSelectedResult();
const mediaStartTime = media?.getStartTime();
const mediaEndTime = media?.getEndTime();
const mediaStartTime = media?.getStartTime() ?? null;
const mediaEndTime = media?.getEndTime() ?? null;
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
const mediaWindow: TimelineWindow | null =
media && mediaStartTime
? // 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) {
desiredWindow = context.window;
} else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
const perfectMediaWindow = this._getPerfectWindowFromMedia(media);
} else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime(
mediaIsEvent,
mediaStartTime,
mediaEndTime,
);
if (perfectMediaWindow) {
desiredWindow = perfectMediaWindow;
}
@@ -1013,22 +1027,22 @@ export class FrigateCardTimelineCore extends LitElement {
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
}
const currentSelection = this._timeline.getSelection();
const mediaID = media?.getID();
if (media && mediaID && this._isClustering()) {
// 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.
const needToSelect = mediaID && mediaIsEvent && !currentSelection.includes(mediaID);
// Need to this rewrite prior to setting the selection (just below), or
// the selection will be lost on rewrite.
this._timelineSource?.rewriteEvent(mediaID);
}
if (needToSelect) {
if (this._isClustering()) {
// 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 =
!!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null;
if (desiredId) {
this._timeline?.setSelection([desiredId], {
// Need to this rewrite prior to setting the selection (just below), or
// the selection will be lost on rewrite.
this._timelineSource?.rewriteEvent(mediaID);
}
this._timeline?.setSelection([mediaID], {
focus: false,
animation: {
animation: false,
@@ -1244,9 +1258,6 @@ export class FrigateCardTimelineCore extends LitElement {
}
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(timelineCoreStyle);
}
+3 -10
View File
@@ -1,8 +1,8 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
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 basicBlockStyle from '../scss/basic-block.scss';
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
import { View } from '../view/view';
import './surround.js';
import './timeline-core.js';
@@ -24,10 +24,6 @@ export class FrigateCardTimeline extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.timelineConfig) {
return html``;
@@ -49,11 +45,8 @@ export class FrigateCardTimeline extends LitElement {
`;
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(timelineStyle);
return unsafeCSS(basicBlockStyle);
}
}
+18 -4
View File
@@ -1,14 +1,28 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
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 { TitleControlConfig } from '../types.js';
import { View } from '../view/view.js';
type PaperToast = HTMLElement & {
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')
export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false })
+257 -101
View File
@@ -10,7 +10,9 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.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 basicBlockStyle from '../../scss/basic-block.scss';
import { CameraManager } from '../camera-manager/manager.js';
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
import { localize } from '../localize/localize.js';
@@ -29,9 +31,14 @@ import {
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.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 { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { MediaGridSelected } from '../utils/media-grid-controller.js';
import {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
@@ -65,6 +72,7 @@ import {
import './next-prev-control.js';
import './surround.js';
import './title-control.js';
import { getDefaultTitleConfigForView } from './title-control.js';
export interface MediaViewerViewContext {
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')
export class FrigateCardViewer extends LitElement {
@property({ attribute: false })
@@ -129,6 +147,7 @@ export class FrigateCardViewer extends LitElement {
this.cardWideConfig,
this.view,
{
allCameras: this.view.isGrid(),
targetView: 'recording',
select: 'latest',
},
@@ -141,6 +160,7 @@ export class FrigateCardViewer extends LitElement {
this.cardWideConfig,
this.view,
{
allCameras: this.view.isGrid(),
targetView: 'media',
mediaType: mediaType,
select: 'latest',
@@ -150,17 +170,15 @@ export class FrigateCardViewer extends LitElement {
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
}
return html`
<frigate-card-viewer-carousel
.hass=${this.hass}
.view=${this.view}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-viewer-carousel>
`;
return html` <frigate-card-viewer-grid
.hass=${this.hass}
.view=${this.view}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-viewer-grid>`;
}
/**
@@ -181,6 +199,9 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public viewFilterCameraID?: string;
// Resetting the viewer configuration causes a full reset so ensure the config
// has actually changed with a full comparison (dynamic configuration
// overrides may causes changes elsewhere in the full card configuration that
@@ -198,7 +219,11 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public selected = 0;
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
protected _media: ViewMedia[] | null = null;
/**
* The updated lifecycle callback for this element.
@@ -255,7 +280,7 @@ export class FrigateCardViewerCarousel extends LitElement {
protected _getPlugins(): EmblaPluginType[] {
return [
// 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({
// 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
* neighbors.
*/
protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] {
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
if (!this.view || !this.view.queryResults || selectedIndex === null) {
return [null, null];
protected _getMediaNeighbors(): MediaNeighbors | null {
const mediaCount = this._media?.length ?? 0;
if (!this._media || this.selected === null) {
return null;
}
const previous: ViewMedia | null =
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null;
const next: ViewMedia | null =
selectedIndex + 1 < resultCount
? this.view.queryResults.getResult(selectedIndex + 1)
: null;
return [previous, next];
const prevIndex = this.selected > 0 ? this.selected - 1 : null;
const nextIndex = this.selected + 1 < mediaCount ? this.selected + 1 : null;
return {
...(prevIndex !== null && {
previous: {
index: prevIndex,
media: this._media[prevIndex],
},
}),
...(nextIndex !== null && {
next: {
index: nextIndex,
media: this._media[nextIndex],
},
}),
};
}
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
@@ -313,23 +346,26 @@ export class FrigateCardViewerCarousel extends LitElement {
}
protected _setViewSelectedIndex(index: number): void {
if (!this.view?.queryResults) {
if (!this._media) {
return;
}
const selectedIndex = this.view.queryResults.getSelectedIndex();
if (selectedIndex === null || selectedIndex === index) {
if (this.selected === null || this.selected === index) {
// 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
// index).
return;
}
const newResults = this.view?.queryResults?.clone().selectResult(index);
const newResults = this.view?.queryResults
?.clone()
.selectIndex(index, this.viewFilterCameraID);
if (!newResults) {
return;
}
const cameraID = newResults.getSelectedResult()?.getCameraID();
const cameraID = newResults
.getSelectedResult(this.viewFilterCameraID)
?.getCameraID();
this.view
?.evolve({
@@ -338,6 +374,7 @@ export class FrigateCardViewerCarousel extends LitElement {
// Always change the camera to the owner of the selected media.
...(cameraID && { camera: cameraID }),
})
.removeContextProperty('mediaViewer', 'seek')
.dispatchChangeEvent(this);
}
@@ -363,13 +400,13 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns The slides to include in the render.
*/
protected _getSlides(): TemplateResult[] {
if (!this.view || !this.view.queryResults) {
if (!this._media) {
return [];
}
const slides: TemplateResult[] = [];
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
const media = this.view.queryResults.getResult(i);
for (let i = 0; i < this._media.length; ++i) {
const media = this._media[i];
if (media) {
const slide = this._renderMediaItem(media, i);
if (slide) {
@@ -388,11 +425,25 @@ export class FrigateCardViewerCarousel extends LitElement {
if (changedProps.has('viewerConfig')) {
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 {
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
if (!resultCount) {
const mediaCount = this._media?.length ?? 0;
if (!this._media || !mediaCount) {
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
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
// avoid rendering a blank. This situation should not occur in practice, as
// this view should not be called without a selected media.
const media =
this.view?.queryResults?.getSelectedResult() ??
this.view?.queryResults?.getResult(resultCount - 1);
if (
!this.hass ||
!this.cameraManager ||
!media ||
!this.view ||
!this.view.queryResults
) {
const selectedMedia = this._media[this.selected] ?? this._media[mediaCount - 1];
if (!this.hass || !this.cameraManager || !selectedMedia) {
return;
}
const [prev, next] = this._getMediaNeighbors();
const neighbors = this._getMediaNeighbors();
const scroll = (direction: 'previous' | 'next'): void => {
const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
if (!this.view || !this.view?.queryResults || currentIndex === null) {
if (!neighbors || !this._media) {
return;
}
const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) {
const newIndex =
(direction === 'previous' ? neighbors.previous?.index : neighbors.next?.index) ??
null;
if (newIndex !== null) {
this._setViewSelectedIndex(newIndex);
}
};
const cameraMetadata = this.cameraManager.getCameraMetadata(
this.hass,
media.getCameraID(),
selectedMedia.getCameraID(),
);
return html` <frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${guard([this.viewerConfig], () => ({
draggable: this.viewerConfig?.draggable ?? true,
}))}
.carouselPlugins=${guard(
[this.viewerConfig, this.view.queryResults.getResults()],
this._getPlugins.bind(this),
)}
.label=${media.getTitle() ?? undefined}
.logo=${cameraMetadata?.engineLogo}
.titlePopupConfig=${this.viewerConfig?.controls.title}
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
>
<frigate-card-next-previous-control
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${prev?.getThumbnail() ?? undefined}
.label=${prev?.getTitle() ?? ''}
?disabled=${!prev}
@click=${(ev) => {
scroll('previous');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
<frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${next?.getThumbnail() ?? undefined}
.label=${next?.getTitle() ?? ''}
?disabled=${!next}
@click=${(ev) => {
scroll('next');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
</frigate-card-media-carousel>`;
const titleConfig = getDefaultTitleConfigForView(
this.view,
this.viewerConfig?.controls.title,
);
return html`
<frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${guard([this.viewerConfig], () => ({
draggable: this.viewerConfig?.draggable ?? true,
}))}
.carouselPlugins=${guard(
[this.viewerConfig, this._media],
this._getPlugins.bind(this),
)}
.label=${selectedMedia.getTitle() ?? undefined}
.logo=${cameraMetadata?.engineLogo}
.titlePopupConfig=${titleConfig ?? undefined}
.selected=${this.selected ?? 0}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
>
<frigate-card-next-previous-control
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
.label=${neighbors?.previous?.media.getTitle() ?? ''}
?disabled=${!neighbors?.previous}
@click=${(ev: Event) => {
scroll('previous');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${guard(this._media, () => this._getSlides())}
<frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
.label=${neighbors?.next?.media.getTitle() ?? ''}
?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> {
const seek = this.view?.context?.mediaViewer?.seek;
const media = this.view?.queryResults?.getSelectedResult();
if (!this.hass || !media || !seek) {
if (!this.hass || !seek || !this._media || this.selected === null) {
return;
}
const selectedMedia = this._media[this.selected];
if (!selectedMedia) {
return;
}
const seekTimeInMedia = selectedMedia.includesTime(seek);
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
if (!seekTimeInMedia) {
this._getPlayer()?.pause();
} else {
this._getPlayer()?.play();
}
const seekTime =
(await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null;
(await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ??
null;
const player = this._getPlayer();
if (player && seekTime !== null) {
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)
export class FrigateCardViewerProvider
extends LitElement
@@ -679,7 +834,7 @@ export class FrigateCardViewerProvider
return;
}
const results = new MediaQueriesResults(mediaArray);
const results = new MediaQueriesResults({ results: mediaArray });
results.selectResultIfFound(
(clipMedia) => clipMedia.getID() === this.media?.getID(),
);
@@ -830,6 +985,7 @@ declare global {
interface HTMLElementTagNameMap {
'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
'frigate-card-viewer': FrigateCardViewer;
'frigate-card-viewer-grid': FrigateCardViewerGrid;
FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider;
}
}
+2 -1
View File
@@ -199,7 +199,8 @@ export class FrigateCardViews extends LitElement {
<frigate-card-live
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live}
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
.overriddenLiveConfig=${this.config.live}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
.cameraManager=${this.cameraManager}
+9 -3
View File
@@ -7,6 +7,7 @@ import {
frigateConditionalSchema,
OverrideConfigurationKey,
RawFrigateCardConfig,
ViewDisplayMode
} from './types';
interface ConditionState {
@@ -16,6 +17,7 @@ interface ConditionState {
camera?: string;
state?: HassEntities;
media_loaded?: boolean;
displayMode?: ViewDisplayMode;
}
export class ConditionEvaluateRequestEvent extends Event {
@@ -176,10 +178,10 @@ export class ConditionController {
}
if (condition.fullscreen !== undefined) {
result &&=
state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
state.fullscreen !== undefined && condition.fullscreen === state.fullscreen;
}
if (condition.expand !== undefined) {
result &&= state.expand !== undefined && condition.expand == state.expand;
result &&= state.expand !== undefined && condition.expand === state.expand;
}
if (condition.camera?.length) {
result &&= !!state.camera && condition.camera.includes(state.camera);
@@ -198,11 +200,15 @@ export class ConditionController {
}
if (condition.media_loaded !== undefined) {
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) {
result &&= window.matchMedia(condition.media_query).matches;
}
if (condition.display_mode) {
result &&= !!state.displayMode && condition.display_mode === state.displayMode;
}
return result;
}
+17 -2
View File
@@ -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_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_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_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
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_DURATION_SECONDS =
`${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_POSITION_X = `${CONF_LIVE}.layout.position.x` 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
// gallery chunks). Smaller values will cause more frequent smaller fetches, but
// 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 FRIGATE_BUTTON_MENU_ICON = 'frigate';
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
+76 -3
View File
@@ -78,6 +78,10 @@ import {
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
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_LAYOUT_FIT,
CONF_LIVE_LAYOUT_POSITION_X,
@@ -117,6 +121,10 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
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_LAYOUT_FIT,
CONF_MEDIA_VIEWER_LAYOUT_POSITION_X,
@@ -126,8 +134,8 @@ import {
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_POSITION,
CONF_MENU_STYLE,
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 {
BUTTON_SIZE_MIN,
FRIGATE_MENU_PRIORITY_MAX,
FrigateCardConfig,
frigateCardConfigDefaults,
FRIGATE_MENU_PRIORITY_MAX,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
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_TIMELINE = 'live.controls.timeline';
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
const MENU_LIVE_DISPLAY = 'live.display';
const MENU_LIVE_LAYOUT = 'live.layout';
const MENU_LIVE_MICROPHONE = 'live.microphone';
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_TIMELINE = 'media_viewer.controls.timeline';
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_OPTIONS = 'options';
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') },
];
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 {
// 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
@@ -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.
* @param domain The submenu domain.
@@ -1785,7 +1844,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
)}`,
)}
${this._renderMenuButton('play') /* */}
${this._renderMenuButton('mute')}
${this._renderMenuButton('mute') /* */}
${this._renderMenuButton('screenshot')}
</div>
`
@@ -1826,6 +1885,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_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(
MENU_LIVE_CONTROLS,
true,
@@ -1957,6 +2023,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_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(
MENU_MEDIA_VIEWER_CONTROLS,
true,
+15
View File
@@ -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": {
"fit": "Layout fit",
"fits": {
@@ -269,6 +280,7 @@
"cameras": "Cameras",
"clips": "Clips",
"download": "Download",
"display_mode": "Display mode",
"enabled": "Button enabled",
"expand": "Expand",
"frigate": "Frigate menu / Default view",
@@ -454,6 +466,9 @@
"what": "What",
"where": "Where"
},
"media_viewer": {
"unseekable": "Seek time not found in media"
},
"media_filter": {
"all": "All",
"camera": "Camera",
+14
View File
@@ -148,6 +148,16 @@
}
}
},
"display": {
"editor_label": "",
"grid_columns": "",
"grid_max_columns": "",
"grid_selected_width_factor": "",
"modes": {
"single": "",
"grid": ""
}
},
"layout": {
"fit": "Adatta al layout",
"fits": {
@@ -267,6 +277,7 @@
"camera_ui": "Interfaccia utente della fotocamera",
"cameras": "Telecamere",
"clips": "Clip",
"display_mode": "",
"download": "Download",
"enabled": "Pulsante abilitato",
"expand": "Espandere",
@@ -473,6 +484,9 @@
},
"where": "Dove"
},
"media_viewer": {
"unseekable": ""
},
"recording": {
"camera": "Camera",
"duration": "Durata",
+15
View File
@@ -148,6 +148,17 @@
}
}
},
"display": {
"editor_label": "",
"grid_columns": "",
"grid_max_columns": "",
"grid_selected_width_factor": "",
"mode": "",
"modes": {
"single": "",
"grid": ""
}
},
"layout": {
"fit": "Ajuste de layout",
"fits": {
@@ -268,6 +279,7 @@
"camera_ui": "Interface de usuário da câmera",
"cameras": "Selecionar câmera",
"clips": "Clipes",
"display_mode": "",
"download": "Baixe a mídia do evento",
"enabled": "Botão ativado",
"expand": "Expandir",
@@ -482,6 +494,9 @@
},
"where": "Onde"
},
"media_viewer": {
"unseekable": ""
},
"recording": {
"camera": "Câmera",
"duration": "Duração",
+15
View File
@@ -148,6 +148,17 @@
}
}
},
"display": {
"editor_label": "",
"grid_columns": "",
"grid_max_columns": "",
"grid_selected_width_factor": "",
"mode": "",
"modes": {
"single": "",
"grid": ""
}
},
"layout": {
"fit": "Fit",
"fits": {
@@ -260,6 +271,7 @@
"camera_ui": "Camera",
"cameras": "Selecionar câmera",
"clips": "Clipes",
"display_mode": "",
"download": "Descarregar mídia do evento",
"enabled": "Botão ativado",
"expand": "Expandir",
@@ -465,6 +477,9 @@
},
"where": "Onde"
},
"media_viewer": {
"unseekable": ""
},
"recording": {
"camera": "Camera",
"duration": "Duração",
+9
View File
@@ -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 {
height: 100%;
flex: 0 0 100%;
-5
View File
@@ -1,5 +0,0 @@
:host {
width: 100%;
height: 100%;
display: block;
}
+1 -1
View File
@@ -21,4 +21,4 @@
frigate-card-select {
padding: 5px;
}
}
+50
View File
@@ -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;
}
-5
View File
@@ -1,5 +0,0 @@
:host {
width: 100%;
height: 100%;
display: block;
}
-5
View File
@@ -1,5 +0,0 @@
:host {
width: 100%;
height: 100%;
display: block;
}
+2
View File
@@ -1,6 +1,8 @@
:host {
--paper-toast-background-color: rgba(0,0,0,0.6);
--paper-toast-color: white;
pointer-events: none;
}
paper-toast {
+32
View File
@@ -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 {
height: 100%;
flex: 0 0 100%;
+28 -15
View File
@@ -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)
*/
@@ -231,6 +242,7 @@ const FRIGATE_CARD_ACTIONS = [
'camera_select',
'live_substream_select',
'media_player',
'display_mode_select',
] as const;
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
@@ -256,6 +268,12 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex
media_player: z.string(),
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([
frigateCardViewActionSchema,
@@ -263,6 +281,7 @@ export const frigateCardCustomActionSchema = z.union([
frigateCardCameraSelectActionSchema,
frigateCardLiveDependencySelectActionSchema,
frigateCardMediaPlayerActionSchema,
frigateCardViewDisplayModeActionSchema,
]);
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
@@ -647,6 +666,7 @@ export const frigateCardConditionSchema = z.object({
media_loaded: z.boolean().optional(),
state: stateConditions.optional(),
media_query: z.string().optional(),
display_mode: viewDisplayModeSchema.optional(),
});
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
@@ -946,6 +966,7 @@ const liveConfigDefault = {
zoomable: true,
transition_effect: 'slide' as const,
show_image_during_load: true,
mode: 'single' as const,
controls: {
builtin: true,
next_previous: {
@@ -954,10 +975,6 @@ const liveConfigDefault = {
},
thumbnails: liveThumbnailControlsDefaults,
timeline: miniTimelineConfigDefault,
title: {
mode: 'popup-bottom-right' as const,
duration_seconds: 2,
},
},
microphone: {
...microphoneConfigDefault,
@@ -990,16 +1007,7 @@ const liveOverridableConfigSchema = z
liveConfigDefault.controls.thumbnails,
),
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
title: titleControlConfigSchema
.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),
title: titleControlConfigSchema.optional(),
})
.default(liveConfigDefault.controls),
show_image_during_load: z
@@ -1008,6 +1016,7 @@ const liveOverridableConfigSchema = z
layout: mediaLayoutConfigSchema.optional(),
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
zoomable: z.boolean().default(liveConfigDefault.zoomable),
display: viewDisplaySchema,
})
.merge(actionsSchema);
@@ -1078,6 +1087,7 @@ const menuConfigDefault = {
play: hiddenButtonDefault,
recordings: hiddenButtonDefault,
screenshot: hiddenButtonDefault,
display_mode: visibleButtonDefault,
},
button_size: 40,
};
@@ -1124,6 +1134,7 @@ const menuConfigSchema = z
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
display_mode: visibleButtonSchema.default(menuConfigDefault.buttons.display_mode),
})
.default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
@@ -1144,6 +1155,7 @@ const viewerConfigDefault = {
zoomable: true,
transition_effect: 'slide' as const,
snapshot_click_plays_clip: true,
display_mode: 'single' as const,
controls: {
builtin: true,
next_previous: {
@@ -1190,6 +1202,7 @@ const viewerConfigSchema = z
snapshot_click_plays_clip: z
.boolean()
.default(viewerConfigDefault.snapshot_click_plays_clip),
display: viewDisplaySchema,
controls: z
.object({
builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
@@ -1373,7 +1386,7 @@ const performanceConfigDefault = {
},
};
const performanceConfigSchema = z
export const performanceConfigSchema = z
.object({
profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile),
features: z
+13
View File
@@ -11,6 +11,7 @@ import {
FrigateCardCustomAction,
frigateCardCustomActionSchema,
FrigateCardViewAction,
ViewDisplayMode,
} from '../types.js';
/**
@@ -42,6 +43,7 @@ export function createFrigateCardCustomAction(
camera?: string;
media_player?: string;
media_player_action?: 'play' | 'stop';
display_mode?: ViewDisplayMode;
},
): FrigateCardCustomAction | null {
if (action === 'camera_select' || action === 'live_substream_select') {
@@ -67,6 +69,17 @@ export function createFrigateCardCustomAction(
...(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 {
action: 'fire-dom-event',
frigate_card_action: action,
+5
View File
@@ -221,3 +221,8 @@ export const setOrRemoveAttribute = (
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;
+328
View File
@@ -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
}`,
);
}
}
+30
View File
@@ -111,3 +111,33 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
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
View File
@@ -1,20 +1,20 @@
import { HomeAssistant } from 'custom-card-helpers';
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 { View } from '../view/view';
import { ViewMedia } from '../view/media';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} 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 { View } from '../view/view';
import { errorToConsole } from './basic';
import { MediaQuery } from '../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
import { getAllDependentCameras } from './camera.js';
type ResultSelectType = 'latest' | 'time' | 'none';
@@ -25,13 +25,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
cardWideConfig: CardWideConfig,
view: View,
options?: {
allCameras?: boolean;
mediaType?: ClipsOrSnapshotsOrAll;
targetView?: FrigateCardView;
select?: ResultSelectType;
},
): Promise<void> => {
const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
if (!cameraIDs) {
const cameraIDs = options?.allCameras
? cameraManager.getStore().getVisibleCameraIDs()
: getAllDependentCameras(cameraManager, view.camera);
if (!cameraIDs.size) {
return;
}
@@ -84,12 +87,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
cardWideConfig: CardWideConfig,
view: View,
options?: {
allCameras?: boolean;
targetView?: 'recording' | 'recordings';
select?: ResultSelectType;
},
): Promise<void> => {
const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
if (!cameraIDs) {
const cameraIDs = options?.allCameras
? cameraManager.getStore().getVisibleCameraIDs()
: getAllDependentCameras(cameraManager, view.camera);
if (!cameraIDs.size) {
return;
}
@@ -161,12 +167,7 @@ export const executeMediaQueryForView = async (
return null;
}
const queryResults = new MediaQueriesResults(
mediaArray,
options?.select === 'latest' && mediaArray.length
? mediaArray.length - 1
: undefined,
);
const queryResults = new MediaQueriesResults({ results: mediaArray });
let viewerContext: ViewContext | undefined = {};
if (options?.select === 'time' && options?.targetTime) {
@@ -180,16 +181,14 @@ export const executeMediaQueryForView = async (
};
}
return (
view
?.evolve({
query: query,
queryResults: queryResults,
view: options?.targetView,
camera: options?.targetCameraID,
})
.mergeInContext(viewerContext) ?? null
);
return view
.evolve({
query: query,
queryResults: queryResults,
view: options?.targetView,
camera: options?.targetCameraID,
})
.mergeInContext(viewerContext);
};
/**
@@ -201,7 +200,7 @@ export const executeMediaQueryForView = async (
*/
export const findBestMediaIndex = (
mediaArray: ViewMedia[],
targetTime: Date
targetTime: Date,
): number | null => {
let bestMatch:
| {
+24 -6
View File
@@ -5,11 +5,11 @@ import { CameraManager } from '../camera-manager/manager';
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
import { localize } from '../localize/localize.js';
import {
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
FrigateCardCustomAction,
MediaLoadedInfo,
MenuButton,
FrigateCardConfig,
FrigateCardCustomAction,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
MediaLoadedInfo,
MenuButton,
} from '../types';
import { View } from '../view/view';
import { createFrigateCardCustomAction } from './action';
@@ -388,10 +388,28 @@ export class MenuButtonController {
...config.menu.buttons.screenshot,
type: 'custom:frigate-card-menu-icon',
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) => ({
style: this._getStyleFromActions(config, view, button),
...button,
+10 -5
View File
@@ -141,15 +141,20 @@ export class Zoom {
}
public deactivate(): void {
const unregisterListener = (events: string[], func: (ev: Event) => void) => {
const unregisterListener = (
events: string[],
func: (ev: Event) => void,
options?: EventListenerOptions,
) => {
events.forEach((eventName) => {
this._element.removeEventListener(eventName, func);
this._element.removeEventListener(eventName, func, options);
});
};
unregisterListener(this._events['down'], this._downHandler);
unregisterListener(this._events['move'], this._moveHandler);
unregisterListener(this._events['up'], this._upHandler);
unregisterListener(this._events['down'], this._downHandler, { capture: true });
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
unregisterListener(this._events['up'], this._upHandler, { capture: true });
unregisterListener(['wheel'], this._wheelHandler);
unregisterListener(['click'], this._clickHandler, { capture: true });
}
}
+208 -60
View File
@@ -1,40 +1,151 @@
import clone from 'lodash-es/clone.js';
import { isSuperset } from '../utils/basic.js';
import { ViewMedia } from './media.js';
export class MediaQueriesResults {
protected _results: ViewMedia[] | null = null;
protected _resultsTimestamp: Date | null = null;
protected _selectedIndex: number | null = null;
type CameraResultSlices = Map<string, ResultSlice>;
type SelectApproach = 'first' | 'last';
constructor(results?: ViewMedia[], selectedIndex?: number | null) {
if (results) {
this.setResults(results);
interface ResultSliceOptions {
results?: ViewMedia[];
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) {
this.selectResult(selectedIndex);
if (options?.results && options.results.length) {
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 {
// 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
// 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).
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 {
if (!this._results || !that._results) {
return false;
}
const thisMediaIDs = new Set(this._results.map((media) => media.getID()));
const thatMediaIDs = new Set(that._results.map((media) => media.getID()));
const thisMediaIDs = new Set(this._main.getResults()?.map((media) => media.getID()));
const thatMediaIDs = new Set(that._main.getResults()?.map((media) => media.getID()));
if (
!thisMediaIDs ||
!thatMediaIDs ||
!thisMediaIDs.size ||
!thatMediaIDs.size ||
// 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
// reduce to the same null identifier above.
@@ -46,68 +157,105 @@ export class MediaQueriesResults {
return isSuperset(thisMediaIDs, thatMediaIDs);
}
public getResults(): ViewMedia[] | null {
return this._results;
public getCameraIDs(): Set<string> {
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[]) {
this._results = results;
this._resultsTimestamp = new Date();
public getResultsCount(cameraID?: string): number {
return this.getSlice(cameraID)?.getResultsCount() ?? 0;
}
public getResult(index?: number): ViewMedia | null {
if (!this._results || index === undefined) {
return null;
}
return this._results[index];
public hasResults(cameraID?: string): boolean {
return this.getSlice(cameraID)?.getResultsCount() !== 0;
}
public getSelectedResult(): ViewMedia | null {
return this._selectedIndex === null ? null : this.getResult(this._selectedIndex);
public getResult(index?: number, cameraID?: string): ViewMedia | null {
return this.getSlice(cameraID)?.getResult(index) ?? null;
}
public getSelectedIndex(): number | null {
return this._selectedIndex;
public getSelectedIndex(cameraID?: string): number | null {
return this.getSlice(cameraID)?.getSelectedIndex() ?? null;
}
public hasSelectedResult(): boolean {
return this.getSelectedResult() !== null;
public getSelectedResult(cameraID?: string): ViewMedia | null {
return this.getSlice(cameraID)?.getSelectedResult() ?? null;
}
public resetSelectedResult(): MediaQueriesResults {
this._selectedIndex = null;
public hasSelectedResult(cameraID?: string): boolean {
return this.getSlice(cameraID)?.hasSelectedResult() ?? false;
}
public resetSelectedResult(cameraID?: string): MediaQueriesResults {
this.getSlice(cameraID)?.resetSelectedResult();
return this;
}
public getResultsTimestamp(): Date | null {
return this._resultsTimestamp;
}
public selectResult(index: number | null): MediaQueriesResults {
if (
index === null ||
(this._results && index >= 0 && index < this._results.length)
) {
this._selectedIndex = index;
public selectIndex(index: number, cameraID?: string): MediaQueriesResults {
this.getSlice(cameraID)?.selectIndex(index);
if (!cameraID) {
// If the main selection is changed, it must also change the matching
// camera selection.
this.demoteMainSelectionToCameraSelection();
}
return this;
}
public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults {
for (const [index, result] of this._results?.entries() ?? []) {
if (func(result)) {
this._selectedIndex = index;
break;
}
public demoteMainSelectionToCameraSelection(): MediaQueriesResults {
const selected = this.getSelectedResult();
if (selected) {
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;
}
public selectBestResult(
func: (media: ViewMedia[]) => number | null,
criteria?: ResultSliceSelectionCriteria,
): MediaQueriesResults {
if (this._results) {
const resultIndex = func(this._results);
if (resultIndex !== null) {
this._selectedIndex = resultIndex;
}
if (!criteria || criteria.main) {
this._main.selectBestResult(func);
this.demoteMainSelectionToCameraSelection();
}
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
for (const cameraID of cameraIDs ?? []) {
this.getSlice(cameraID)?.selectBestResult(func);
}
return this;
}
+11 -1
View File
@@ -23,14 +23,24 @@ class MediaQueriesBase<T extends MediaQuery> {
public setQueries(queries: T[]): void {
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> {
public convertToClipsQueries(): void {
public convertToClipsQueries(): this {
for (const query of this._queries ?? []) {
delete query.hasSnapshot;
query.hasClip = true;
}
return this;
}
public clone(): EventMediaQueries {
-3
View File
@@ -13,9 +13,6 @@ export class ViewMedia {
this._mediaType = mediaType;
this._cameraID = cameraID;
}
public getContentType(): 'image' | 'video' {
return this._mediaType === 'snapshot' ? 'image' : 'video';
}
public getCameraID(): string {
return this._cameraID;
}
+29 -1
View File
@@ -1,5 +1,5 @@
import { ViewContext } from 'view';
import { ClipsOrSnapshots, FrigateCardView } from '../types.js';
import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { MediaQueries } from './media-queries';
import { MediaQueriesClassifier } from './media-queries-classifier.js';
@@ -11,6 +11,7 @@ interface ViewEvolveParameters {
query?: MediaQueries | null;
queryResults?: MediaQueriesResults | null;
context?: ViewContext | null;
displayMode?: ViewDisplayMode | null;
}
export interface ViewParameters extends ViewEvolveParameters {
@@ -24,6 +25,7 @@ export class View {
public query: MediaQueries | null;
public queryResults: MediaQueriesResults | null;
public context: ViewContext | null;
public displayMode: ViewDisplayMode | null;
constructor(params: ViewParameters) {
this.view = params.view;
@@ -31,6 +33,7 @@ export class View {
this.query = params.query ?? null;
this.queryResults = params.queryResults ?? null;
this.context = params.context ?? null;
this.displayMode = params.displayMode ?? null;
}
/**
@@ -142,6 +145,7 @@ export class View {
query: this.query?.clone() ?? null,
queryResults: this.queryResults?.clone() ?? null,
context: this.context,
displayMode: this.displayMode,
});
}
@@ -160,6 +164,8 @@ export class View {
? params.queryResults
: this.queryResults?.clone() ?? null,
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;
}
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.
*/
@@ -214,6 +231,13 @@ export class 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.
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
@@ -232,6 +256,10 @@ export class View {
return null;
}
public isGrid(): boolean {
return this.displayMode === 'grid';
}
/**
* Dispatch an event to request a view change.
* @param target The target dispatching the event.