diff --git a/.vscode/settings.json b/.vscode/settings.json index 31c57af5..1814a117 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "i18n-ally.keepFulfilled": true, "i18n-ally.editor.preferEditor": true, "i18n-ally.translate.saveAsCandidates": true, - "vitest.commandLine": "npx vitest --root ." + "vitest.commandLine": "npx vitest --root .", + "diffEditor.experimental.useVersion2": true } diff --git a/package.json b/package.json index 260fa40f..ef7c35fd 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "lit": "^2.3.1", "lit-flatpickr": "^0.4.0", "lodash-es": "^4.17.21", + "masonry-layout": "^4.2.2", "moment": "^2.29.4", "propagating-hammerjs": "^2.0.1", "quick-lru": "^6.1.0", @@ -58,6 +59,7 @@ "@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-replace": "^4.0.0", "@types/lodash-es": "^4.17.5", + "@types/masonry-layout": "^4.2.5", "@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/parser": "^5.36.2", "@vitest/coverage-c8": "^0.29.8", diff --git a/src/card.ts b/src/card.ts index b0ead46e..21f182b9 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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)', ); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index c331be0d..fcf6fed6 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -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, diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 58e4a03d..3aad8aa6 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -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 { - 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); } } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 23e34688..cdbf87e8 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -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; @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` - - + `, )}`; @@ -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; @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` + + + `; + } + + 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` + ) => + this._gridSelectCamera(ev.detail.selected)} + @frigate-card:view:change=${(ev: CustomEvent) => { + ev.stopPropagation(); + this._gridSelectCamera(ev.detail.camera, ev.detail); + }} + > + ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} + + `; + } + + 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; + + @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 = {}; protected _refMediaCarousel: Ref = 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] { - 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 = {}; - 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`
{ 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; } } diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 5e9df428..470e564b 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -428,9 +428,6 @@ export class FrigateCardMediaCarousel extends LitElement { : ``}`; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(mediaCarouselStyle); } diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts new file mode 100644 index 00000000..e0b599b3 --- /dev/null +++ b/src/components/media-grid.ts @@ -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 = 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` `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(mediaGridStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-grid': FrigateCardMediaGrid; + } +} diff --git a/src/components/surround.ts b/src/components/surround.ts index 79f69242..ec771339 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -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 { `; } - /** - * Return compiled CSS styles. - */ static get styles(): CSSResultGroup { - return unsafeCSS(surroundStyle); + return unsafeCSS(basicBlockStyle); } } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index cc550fe7..c7a20351 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -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), }, ); } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 18eff32e..8b33f147 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -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); } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index f70a7db1..4f3cce06 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -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); } } diff --git a/src/components/title-control.ts b/src/components/title-control.ts index 16f14c9e..7bbda1e9 100644 --- a/src/components/title-control.ts +++ b/src/components/title-control.ts @@ -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, + 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 }) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 37980d93..fd030904 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -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` - - - `; + return html` + `; } /** @@ -181,6 +199,9 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public view?: Readonly; + @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 = 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): 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` ({ - 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)} - > - { - scroll('previous'); - stopEventFromActivatingCardWideActions(ev); - }} - > - ${guard(this.view?.queryResults?.getResults(), () => this._getSlides())} - { - scroll('next'); - stopEventFromActivatingCardWideActions(ev); - }} - > - `; + const titleConfig = getDefaultTitleConfigForView( + this.view, + this.viewerConfig?.controls.title, + ); + + return html` + ({ + 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)} + > + { + scroll('previous'); + stopEventFromActivatingCardWideActions(ev); + }} + > + ${guard(this._media, () => this._getSlides())} + { + scroll('next'); + stopEventFromActivatingCardWideActions(ev); + }} + > + +
+ + +
+ `; } /** @@ -484,13 +539,26 @@ export class FrigateCardViewerCarousel extends LitElement { */ protected async _seekHandler(): Promise { 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; + + @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` + + + `; + } + + 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` + ) => + this._gridSelectCamera(ev.detail.selected)} + @frigate-card:view:change=${(ev: CustomEvent) => { + ev.stopPropagation(); + const childView = ev.detail; + this._gridSelectCamera(childView.camera, childView); + }} + > + ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} + + `; + } + + 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; } } diff --git a/src/components/views.ts b/src/components/views.ts index f7032397..97a9a54b 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -199,7 +199,8 @@ export class FrigateCardViews extends LitElement { ` @@ -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, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a5b5b780..e84d933f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 2643abd5..35a150c4 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -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", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index c873ceb9..1609d398 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -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", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 6f4cd0ef..57fc800f 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -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", diff --git a/src/scss/live.scss b/src/scss/basic-block.scss similarity index 100% rename from src/scss/live.scss rename to src/scss/basic-block.scss diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss index aab21ed3..d4dafaf1 100644 --- a/src/scss/live-carousel.scss +++ b/src/scss/live-carousel.scss @@ -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%; diff --git a/src/scss/live-image.scss b/src/scss/live-image.scss deleted file mode 100644 index f0f64c07..00000000 --- a/src/scss/live-image.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host { - width: 100%; - height: 100%; - display: block; -} \ No newline at end of file diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss index 46ad7beb..6dc9aa2f 100644 --- a/src/scss/media-filter.scss +++ b/src/scss/media-filter.scss @@ -21,4 +21,4 @@ frigate-card-select { padding: 5px; -} \ No newline at end of file +} diff --git a/src/scss/media-grid.scss b/src/scss/media-grid.scss new file mode 100644 index 00000000..a6b9b49c --- /dev/null +++ b/src/scss/media-grid.scss @@ -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; +} diff --git a/src/scss/surround.scss b/src/scss/surround.scss deleted file mode 100644 index 9f92a4e0..00000000 --- a/src/scss/surround.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host { - width: 100%; - height: 100%; - display: block; -} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss deleted file mode 100644 index f0f64c07..00000000 --- a/src/scss/timeline.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host { - width: 100%; - height: 100%; - display: block; -} \ No newline at end of file diff --git a/src/scss/title-control.scss b/src/scss/title-control.scss index 6f2c9264..ca49513a 100644 --- a/src/scss/title-control.scss +++ b/src/scss/title-control.scss @@ -1,6 +1,8 @@ :host { --paper-toast-background-color: rgba(0,0,0,0.6); --paper-toast-color: white; + + pointer-events: none; } paper-toast { diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss index aab21ed3..b7bc6bfb 100644 --- a/src/scss/viewer-carousel.scss +++ b/src/scss/viewer-carousel.scss @@ -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%; diff --git a/src/types.ts b/src/types.ts index 7f3e0f7d..d98ec638 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,17 @@ export class FrigateCardError extends Error { } } +const viewDisplayModeSchema = z.enum(['single', 'grid']); +export type ViewDisplayMode = z.infer; + +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; + /** * 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; @@ -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; @@ -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 diff --git a/src/utils/action.ts b/src/utils/action.ts index e065d7be..2c7ce74a 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -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, diff --git a/src/utils/basic.ts b/src/utils/basic.ts index ac4b9a2d..2321fe5e 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -221,3 +221,8 @@ export const setOrRemoveAttribute = ( element.removeAttribute(name); } }; + +/** + * Allow typescript to narrow types based on truthy filter. + */ +export const filterTruthy = (x: T | false | undefined | null | '' | 0): x is T => !!x; diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts new file mode 100644 index 00000000..6f09dfb9 --- /dev/null +++ b/src/utils/media-grid-controller.ts @@ -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; + +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 = 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): 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 + }`, + ); + } +} diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index 22637d7d..3e01807a 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -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, + ) => 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, + ) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 655b5ca9..76215f78 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -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 => { - 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 => { - 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: | { diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index 18d9a841..64123abd 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -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, diff --git a/src/utils/zoom/zoom.ts b/src/utils/zoom/zoom.ts index b3fc9d0d..08f1ce06 100644 --- a/src/utils/zoom/zoom.ts +++ b/src/utils/zoom/zoom.ts @@ -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 }); } } diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts index e0d9f5d4..cde420c2 100644 --- a/src/view/media-queries-results.ts +++ b/src/view/media-queries-results.ts @@ -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; +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 = 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 { + 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 | 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; } diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 718c1d98..2a75e5e3 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -23,14 +23,24 @@ class MediaQueriesBase { public setQueries(queries: T[]): void { this._queries = queries; } + + public hasQueriesForCameraIDs(cameraIDs: Set) { + for (const cameraID of cameraIDs) { + if (!this._queries?.some((query) => query.cameraIDs.has(cameraID))) { + return false; + } + } + return true; + } } export class EventMediaQueries extends MediaQueriesBase { - public convertToClipsQueries(): void { + public convertToClipsQueries(): this { for (const query of this._queries ?? []) { delete query.hasSnapshot; query.hasClip = true; } + return this; } public clone(): EventMediaQueries { diff --git a/src/view/media.ts b/src/view/media.ts index abb62371..369aa63e 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -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; } diff --git a/src/view/view.ts b/src/view/view.ts index 0f417081..e0f13ec1 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -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. diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts index 0a870cc4..913ad754 100644 --- a/tests/camera-manager/utils.test.ts +++ b/tests/camera-manager/utils.test.ts @@ -76,30 +76,26 @@ describe('capEndDate', () => { }); describe('sortMedia', () => { - const media_1 = new TestViewMedia( - 'id-1', - new Date('2023-04-29T14:25'), - 'clip', - 'camera-1', - ); - const media_2 = new TestViewMedia( - 'id-2', - new Date('2023-04-29T14:26'), - 'clip', - 'camera-1', - ); - const media_3_dup_id = new TestViewMedia( - 'id-2', - new Date('2023-04-29T14:26'), - 'clip', - 'camera-1', - ); - const media_4_no_id = new TestViewMedia( - null, - new Date('2023-04-29T14:27'), - 'clip', - 'camera-1', - ); + const media_1 = new TestViewMedia({ + id: 'id-1', + startTime: new Date('2023-04-29T14:25'), + cameraID: 'camera-1', + }); + const media_2 = new TestViewMedia({ + id: 'id-2', + startTime: new Date('2023-04-29T14:26'), + cameraID: 'camera-1', + }); + const media_3_dup_id = new TestViewMedia({ + id: 'id-2', + startTime: new Date('2023-04-29T14:26'), + cameraID: 'camera-1', + }); + const media_4_no_id = new TestViewMedia({ + id: null, + startTime: new Date('2023-04-29T14:27'), + cameraID: 'camera-1', + }); it('should sort sorted media', () => { const media = [media_1, media_2]; diff --git a/tests/conditions.test.ts b/tests/conditions.test.ts index 3c98db99..d5eb3fbc 100644 --- a/tests/conditions.test.ts +++ b/tests/conditions.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConditionController, ConditionEvaluateRequestEvent, @@ -6,6 +6,7 @@ import { getOverriddenConfig, getOverridesByKey, } from '../src/conditions'; +import { FrigateCardCondition } from '../src/types'; import { createCondition, createConfig, createStateEntity } from './test-utils'; // @vitest-environment jsdom @@ -347,4 +348,14 @@ describe('ConditionController', () => { controller.destroy(); expect(removeEventListener).toBeCalled(); }); + + it('should evaluate conditions with display mode', () => { + const controller = new ConditionController(); + const condition: FrigateCardCondition = { display_mode: 'grid' }; + expect(controller.evaluateCondition(condition)).toBeFalsy(); + controller.setState({ displayMode: 'grid' }); + expect(controller.evaluateCondition(condition)).toBeTruthy(); + controller.setState({ displayMode: 'single' }); + expect(controller.evaluateCondition(condition)).toBeFalsy(); + }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index e7bda43d..1dc0bcec 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -9,6 +9,7 @@ import { CameraConfigs, CameraManagerCameraCapabilities, CameraManagerMediaCapabilities, + QueryType, } from '../src/camera-manager/types'; import { CameraConfig, @@ -16,10 +17,12 @@ import { FrigateCardCondition, FrigateCardConfig, MediaLoadedInfo, + PerformanceConfig, RawFrigateCardConfig, cameraConfigSchema, frigateCardConditionSchema, frigateCardConfigSchema, + performanceConfigSchema, } from '../src/types'; import { Entity } from '../src/utils/ha/entity-registry/types'; import { ViewMedia, ViewMediaType } from '../src/view/media'; @@ -126,11 +129,25 @@ export const createCameraManager = (options?: { const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]); vi.mocked(store.getCameras).mockReturnValue(configs); vi.mocked(store.getVisibleCameras).mockReturnValue(configs); + vi.mocked(store.getVisibleCameraIDs).mockReturnValue(new Set(configs.keys())); vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => { return configs.get(cameraID) ?? createCameraConfig(); }); } vi.mocked(cameraManager.getStore).mockReturnValue(store); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ + { + cameraIDs: new Set(['camera']), + type: QueryType.Event, + }, + ]); + vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([ + { + cameraIDs: new Set(['camera']), + type: QueryType.Recording, + }, + ]); + return cameraManager; }; @@ -169,21 +186,44 @@ export const createMediaLoadedInfo = ( }; }; +export const createPerformanceConfig = (config: unknown): PerformanceConfig => { + return performanceConfigSchema.parse(config); +}; + +export const generateViewMediaArray = (options?: { + cameraIDs?: string[]; + count?: number; +}): ViewMedia[] => { + const media: ViewMedia[] = []; + for (let i = 0; i < (options?.count ?? 100); ++i) { + for (const cameraID of options?.cameraIDs ?? ['kitchen', 'office']) { + media.push(new TestViewMedia({ cameraID: cameraID, id: `id-${cameraID}-${i}` })); + } + } + return media; +}; + // ViewMedia itself has no native way to set startTime and ID that aren't linked // to an engine. export class TestViewMedia extends ViewMedia { protected _id: string | null; - protected _startTime: Date; + protected _startTime: Date | null; + protected _endTime: Date | null; + protected _inProgress: boolean | null; - constructor( - id: string | null, - startTime: Date, - mediaType: ViewMediaType, - cameraID: string, - ) { - super(mediaType, cameraID); - this._id = id; - this._startTime = startTime; + constructor(options?: { + id?: string | null; + startTime?: Date; + mediaType?: ViewMediaType; + cameraID?: string; + endTime?: Date; + inProgress?: boolean; + }) { + super(options?.mediaType ?? 'clip', options?.cameraID ?? 'camera'); + this._id = options?.id !== undefined ? options.id : 'id'; + this._startTime = options?.startTime ?? null; + this._endTime = options?.endTime ?? null; + this._inProgress = options?.inProgress !== undefined ? options.inProgress : false; } public getID(): string | null { return this._id; @@ -191,4 +231,25 @@ export class TestViewMedia extends ViewMedia { public getStartTime(): Date | null { return this._startTime; } + public getEndTime(): Date | null { + return this._endTime; + } + public inProgress(): boolean | null { + return this._inProgress; + } } + +export const createResizeObserverImplementation = (): (() => void) => { + return () => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }); +}; + +export const createMutationObserverImplementation = (): (() => void) => { + return () => ({ + observe: vi.fn(), + disconnect: vi.fn(), + }); +}; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index d4ae8af8..3b0e6970 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -103,6 +103,24 @@ describe('createFrigateCardCustomAction', () => { card_id: 'card_id', }); }); + + it('should create display mode action', () => { + expect( + createFrigateCardCustomAction('display_mode_select', { + display_mode: 'grid', + cardID: 'card_id', + }), + ).toEqual({ + action: 'fire-dom-event', + frigate_card_action: 'display_mode_select', + display_mode: 'grid', + card_id: 'card_id', + }); + }); + + it('should not create display mode action without display mode', () => { + expect(createFrigateCardCustomAction('display_mode_select')).toBeNull(); + }); }); describe('getActionConfigGivenAction', () => { diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index a17fd576..6d45f08c 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -8,6 +8,7 @@ import { dayToDate, dispatchFrigateCardEvent, errorToConsole, + filterTruthy, formatDate, formatDateAndTime, getDurationString, @@ -227,7 +228,13 @@ describe('isValidDate', () => { }); describe('setOrRemoveAttribute', () => { - it('should set attribute', () => { + it('should set attribute without value', () => { + const element = document.createElement('div'); + setOrRemoveAttribute(element, true, 'key'); + expect(element.getAttribute('key')).toBe(''); + }); + + it('should set attribute with value', () => { const element = document.createElement('div'); setOrRemoveAttribute(element, true, 'key', 'value'); expect(element.getAttribute('key')).toBe('value'); @@ -240,3 +247,12 @@ describe('setOrRemoveAttribute', () => { expect(element.getAttribute('key')).toBeFalsy(); }); }); + +describe('filterTruthy', () => { + it('should return true for true', () => { + expect(filterTruthy(true)).toBeTruthy(); + }); + it('should return false for false', () => { + expect(filterTruthy(false)).toBeFalsy(); + }); +}); diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts new file mode 100644 index 00000000..255c2105 --- /dev/null +++ b/tests/utils/media-grid-controller.test.ts @@ -0,0 +1,400 @@ +import Masonry from 'masonry-layout'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MediaLoadedInfo } from '../../src/types'; +import { + MediaGridConstructorOptions, + MediaGridController, +} from '../../src/utils/media-grid-controller'; +import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info'; +import { + createMutationObserverImplementation, + createResizeObserverImplementation, +} from '../test-utils'; + +vi.mock('lodash-es/throttle', () => ({ + default: vi.fn((fn) => fn), +})); + +const masonry = mock(); +vi.mock('masonry-layout', () => ({ + default: vi.fn().mockImplementation(() => { + return masonry; + }), +})); + +const createChildren = (childIDs?: string[], idAttribute?: string): HTMLElement[] => { + const children: HTMLElement[] = []; + for (let i = 0; i < (childIDs?.length ?? 3); ++i) { + const child = document.createElement('div'); + if (childIDs) { + child.setAttribute(idAttribute ?? 'grid-id', childIDs[i]); + } + children.push(child); + } + return children; +}; + +const setElementWidth = (element: HTMLElement, width: number): void => { + element.getBoundingClientRect = vi.fn().mockReturnValue({ + width: width, + }); +}; + +const createHost = (options?: { + children?: HTMLElement[]; + width?: number; +}): HTMLElement => { + const host = document.createElement('div'); + if (options?.children) { + host.append(...options.children); + } + // Default Lovelace card width is 492. + setElementWidth(host, options?.width ?? 492); + return host; +}; + +const createSlotParent = (): HTMLElement => { + const parent = document.createElement('div'); + parent.attachShadow({ mode: 'open' }); + return parent; +}; + +const createSlotHost = (options?: { + children?: HTMLElement[]; + parent?: HTMLElement; +}): HTMLElement => { + const parent = options?.parent ?? createSlotParent(); + const slot = document.createElement('slot'); + parent.shadowRoot?.append(slot); + + if (options?.children) { + // Children will automatically be slotted into the default slot. + parent.append(...options.children); + } + + return slot; +}; + +const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => { + return new MediaGridController(host, options); +}; + +const triggerMutationObserver = (): void => { + const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[0][0]; + mutationObserverTrigger([], mock()); +}; + +const triggerResizeObserver = (cellOrHost: 'cell' | 'host'): void => { + const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[ + cellOrHost === 'cell' ? 0 : 1 + ][0]; + resizeObserverTrigger([], mock()); +}; + +// @vitest-environment jsdom +describe('MediaGridController', () => { + const mediaLoadedInfo: MediaLoadedInfo = { + width: 10, + height: 20, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + global.ResizeObserver = vi + .fn() + // Caution: Order must match the order of initialization in + // media-grid-controller.ts . + .mockImplementationOnce(createResizeObserverImplementation()) + .mockImplementationOnce(createResizeObserverImplementation()); + + global.MutationObserver = vi + .fn() + .mockImplementation(createMutationObserverImplementation()); + //global.MutationObserver = mock(); + }); + + it('should be constructable', () => { + const controller = createController(createHost()); + expect(controller).toBeTruthy(); + expect(masonry.layout).toBeCalled(); + }); + + it('should set grid contents correctly from regular elements', () => { + const children = createChildren(); + const host = createHost({ children: children }); + const controller = createController(host); + expect(controller.getGridContents()).toEqual( + new Map([ + ['0', children[0]], + ['1', children[1]], + ['2', children[2]], + ]), + ); + expect(controller.getGridSize()).toBe(3); + expect(masonry.layout).toBeCalled(); + }); + + it('should set grid contents correctly from slotted elements', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + expect(controller.getGridContents()).toEqual( + new Map([ + ['0', children[0]], + ['1', children[1]], + ['2', children[2]], + ]), + ); + expect(controller.getGridSize()).toBe(3); + }); + + it('should select element', () => { + const children = createChildren(); + const controller = createController(createSlotHost({ children: children })); + + // All children should be unselected. + expect(controller.getSelected()).toBeNull(); + for (const child of children) { + expect(child.getAttribute('selected')).toBeNull(); + expect(child.getAttribute('unselected')).toEqual(''); + } + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + + // 1st child should now be selected. + expect(children[0].getAttribute('selected')).toEqual(''); + expect(children[0].getAttribute('unselected')).toBeNull(); + + // 2nd and 3rd should be unselected. + for (const child of children.slice(1)) { + expect(child.getAttribute('selected')).toBeNull(); + expect(child.getAttribute('unselected')).toEqual(''); + } + }); + + it('should re-select element', () => { + const controller = createController(createSlotHost({ children: createChildren() })); + + // All children should be unselected. + expect(controller.getSelected()).toBeNull(); + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + }); + + it('should dispatch media loaded info on selection', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + + const mediaLoadedInfoHandler = vi.fn(); + host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + // Nothing is selected, so the event should not have propagated. + expect(mediaLoadedInfoHandler).not.toBeCalled(); + + controller.selectCell('0'); + expect(mediaLoadedInfoHandler).toBeCalledWith( + expect.objectContaining({ + detail: mediaLoadedInfo, + }), + ); + }); + + it('should unselect', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + + const unselectedHandler = vi.fn(); + const unloadMediaHandler = vi.fn(); + host.addEventListener('frigate-card:media-grid:unselected', unselectedHandler); + host.addEventListener('frigate-card:media:unloaded', unloadMediaHandler); + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + + // Unselect all elements. + controller.unselectAll(); + + // Expect selected to now be null. + expect(controller.getSelected()).toBeNull(); + + // Expect styles to have been updated. + for (const child of children) { + expect(child.getAttribute('selected')).toBeNull(); + expect(child.getAttribute('unselected')).toEqual(''); + } + + // Expect handlers to have been called. + expect(unselectedHandler).toBeCalled(); + expect(unloadMediaHandler).toBeCalled(); + }); + + it('should select in constructor', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host, { selected: '2' }); + expect(controller.getSelected()).toBe('2'); + }); + + it('should respect grid attribute option', () => { + const children = createChildren(['one', 'two', 'three'], 'test-id'); + const host = createSlotHost({ children: children }); + const controller = createController(host, { idAttribute: 'test-id' }); + expect(controller.getGridContents()).toEqual( + new Map([ + ['one', children[0]], + ['two', children[1]], + ['three', children[2]], + ]), + ); + }); + + it('should destroy', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + expect(controller.getGridSize()).toBe(3); + controller.destroy(); + expect(controller.getGridSize()).toBe(0); + }); + + it('should replace children when they change', () => { + const children = createChildren(); + const host = createHost({ children: children }); + const controller = createController(host, { selected: '1' }); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + expect(controller.getSelected()).toBe('1'); + expect(controller.getGridSize()).toBe(3); + + children.forEach((child) => host.removeChild(child)); + const newChildren = createChildren(['one', 'two', 'three']); + newChildren.forEach((child) => host.appendChild(child)); + + triggerMutationObserver(); + + expect(controller.getGridContents()).toEqual( + new Map([ + ['one', newChildren[0]], + ['two', newChildren[1]], + ['three', newChildren[2]], + ]), + ); + expect(controller.getSelected()).toBeNull(); + }); + + it('should construct masonry correctly', () => { + const children = createChildren(); + const host = createHost({ children: children }); + createController(host); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + initLayout: false, + percentPosition: true, + transitionDuration: '0.3s', + }), + ); + }); + + it('should set default column size correctly', () => { + const host = createHost({ children: createChildren() }); + createController(host); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 246, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + }); + + it('should respect exact columns', () => { + const host = createHost({ children: createChildren(), width: 2000 }); + const controller = createController(host); + controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 }); + + // Will have been called once on construction, and then again when the + // number of columns changes. + expect(Masonry).toBeCalledTimes(2); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 1000, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( + '1000px', + ); + }); + + it('should respect selected width factor', () => { + const host = createHost({ children: createChildren(), width: 2000 }); + const controller = createController(host); + controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); + expect( + host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), + ).toBe('3'); + }); + + it('should select cell with interacted with', () => { + const children = createChildren(); + const host = createHost({ children: children, width: 2000 }); + const controller = createController(host); + + expect(controller.getSelected()).toBeNull(); + + const touchEvent = new TouchEvent('touchend'); + children[1].dispatchEvent(touchEvent); + + expect(controller.getSelected()).toBe('1'); + }); + + it('should re-layout when child size changes', () => { + createController(createHost({ children: createChildren() })); + + vi.mocked(masonry.layout)?.mockClear(); + triggerResizeObserver('cell'); + expect(masonry.layout).toBeCalled(); + }); + + it('should re-create masonry when host size changes', () => { + const children = createChildren(); + const host = createHost({ children: children }); + const controller = createController(host); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 246, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + + // Clear mock state. + vi.mocked(Masonry).mockClear(); + vi.mocked(masonry.layout)?.mockClear(); + + // Resize the host. + setElementWidth(host, 2000); + triggerResizeObserver('host'); + + // Masonry should be reconstructed, styles set and layout called. + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 667, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px'); + expect(masonry.layout).toBeCalled(); + }); +}); diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts new file mode 100644 index 00000000..5b108127 --- /dev/null +++ b/tests/utils/media-to-view.test.ts @@ -0,0 +1,452 @@ +import add from 'date-fns/add'; +import sub from 'date-fns/sub'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { + CameraConfigs, + PartialRecordingQuery, + QueryType, +} from '../../src/camera-manager/types'; +import { setify } from '../../src/utils/basic'; +import { + changeViewToRecentEventsForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents, + createQueriesForRecordingsView, + executeMediaQueryForView, + findBestMediaIndex, +} from '../../src/utils/media-to-view'; +import { ViewMedia } from '../../src/view/media'; +import { EventMediaQueries } from '../../src/view/media-queries'; +import { + createCameraManager, + createHASS, + createPerformanceConfig, + createView, + TestViewMedia, +} from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); + +const createElementListenForView = (): { + element: HTMLElement; + viewHandler: Mock; + messageHandler: Mock; +} => { + const element = document.createElement('div'); + + const viewHandler = vi.fn(); + element.addEventListener('frigate-card:view:change', viewHandler); + + const messageHandler = vi.fn(); + element.addEventListener('frigate-card:message', messageHandler); + + return { + element: element, + viewHandler: viewHandler, + messageHandler: messageHandler, + }; +}; + +const getMediaFromHandlerCall = (handler: Mock): ViewMedia[] | null => { + return handler.mock.calls[0][0].detail.queryResults.getResults(); +}; + +const generateViewMedia = ( + index: number, + base: Date, + durationSeconds: number, +): ViewMedia => { + return new TestViewMedia({ + id: `id-${index}`, + startTime: base, + endTime: add(base, { seconds: durationSeconds }), + }); +}; + +// @vitest-environment jsdom +describe('changeViewToRecentEventsForCameraAndDependents', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should do nothing without camera config for selected camera', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing without camera configs for all cameras', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + allCameras: true, + }, + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing unless queries can be created', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + mediaType: 'clips', + }, + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should dispatch new view on success', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + + const mediaArray = [new ViewMedia('clip', 'camera')]; + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + targetView: 'clips', + select: 'latest', + }, + ); + expect(elementHandler.viewHandler).toBeCalled(); + expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray); + }); + + it('should dispatch error message on fail', async () => { + vi.spyOn(global.console, 'warn').mockImplementation(() => true); + + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error()); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + expect(elementHandler.messageHandler).toBeCalled(); + }); + + it('should respect media chunk size', async () => { + const cameraManager = createCameraManager(); + + await changeViewToRecentEventsForCameraAndDependents( + createElementListenForView().element, + createHASS(), + cameraManager, + { + performance: createPerformanceConfig({ + features: { + media_chunk_size: 1000, + }, + }), + }, + createView(), + ); + + expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + limit: 1000, + }), + ); + }); + + describe('should respect request for media type', () => { + it.each([ + ['snapshots' as const, 'hasSnapshot'], + ['clips' as const, 'hasClip'], + ])('%s', async (mediaType, queryParameter) => { + const cameraManager = createCameraManager(); + + await changeViewToRecentEventsForCameraAndDependents( + createElementListenForView().element, + createHASS(), + cameraManager, + {}, + createView(), + { + mediaType: mediaType, + }, + ); + + expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + [queryParameter]: true, + }), + ); + }); + }); +}); + +// @vitest-environment jsdom +describe('executeMediaQueryForView', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should not execute empty queries', async () => { + const elementHandler = createElementListenForView(); + const cameraConfigs: CameraConfigs = new Map(); + const cameraManager = createCameraManager({ configs: cameraConfigs }); + + expect( + await executeMediaQueryForView( + elementHandler.element, + createHASS(), + cameraManager, + createView(), + new EventMediaQueries(), + ), + ).toBeNull(); + }); + + it('should select time-based result', async () => { + const elementHandler = createElementListenForView(); + const cameraConfigs: CameraConfigs = new Map(); + const cameraManager = createCameraManager({ configs: cameraConfigs }); + + const now = new Date(); + const mediaArray = [ + generateViewMedia(0, now, 60), + generateViewMedia(1, now, 120), + generateViewMedia(2, now, 10), + ]; + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + const view = await executeMediaQueryForView( + elementHandler.element, + createHASS(), + cameraManager, + createView(), + new EventMediaQueries( + cameraManager.generateDefaultEventQueries('camera') ?? undefined, + ), + { + select: 'time', + targetTime: add(now, { seconds: 30 }), + }, + ); + + // Should select the longest event. + expect(view?.queryResults?.getSelectedIndex()).toBe(1); + expect(view?.queryResults?.getResults()).toBe(mediaArray); + }); + + it('should select nothing when time-based selection does not match', async () => { + const elementHandler = createElementListenForView(); + const cameraConfigs: CameraConfigs = new Map(); + const cameraManager = createCameraManager({ configs: cameraConfigs }); + + const now = new Date(); + const mediaArray = [ + generateViewMedia(0, now, 60), + generateViewMedia(1, now, 120), + generateViewMedia(2, now, 10), + ]; + + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + const view = await executeMediaQueryForView( + elementHandler.element, + createHASS(), + cameraManager, + createView(), + new EventMediaQueries( + cameraManager.generateDefaultEventQueries('camera') ?? undefined, + ), + { + select: 'time', + targetTime: sub(now, { seconds: 30 }), + }, + ); + + // Should leave selection untouched (last item will remain selected). + expect(view?.queryResults?.getSelectedIndex()).toBe(2); + expect(view?.queryResults?.getResults()).toBe(mediaArray); + }); +}); + +// @vitest-environment jsdom +describe('changeViewToRecentRecordingForCameraAndDependents', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should do nothing without camera config for selected camera', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing without camera configs for all cameras', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + allCameras: true, + }, + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing unless queries can be created', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue(null); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should dispatch new view on success', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + + const mediaArray = [new ViewMedia('recording', 'camera')]; + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + targetView: 'recordings', + select: 'latest', + }, + ); + expect(elementHandler.viewHandler).toBeCalled(); + expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray); + }); + + it('should respect media chunk size', async () => { + const cameraManager = createCameraManager(); + + await changeViewToRecentRecordingForCameraAndDependents( + createElementListenForView().element, + createHASS(), + cameraManager, + { + performance: createPerformanceConfig({ + features: { + media_chunk_size: 1000, + }, + }), + }, + createView(), + ); + + expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + limit: 1000, + }), + ); + }); +}); + +// @vitest-environment jsdom +describe('createQueriesForRecordingsView', () => { + it('should respect start and end date in recording query', async () => { + const cameraManager = createCameraManager({ configs: new Map() }); + + vi.mocked(cameraManager.generateDefaultRecordingQueries).mockImplementation( + (cameraIDs: string | Set, partialQuery?: PartialRecordingQuery) => [ + { + cameraIDs: setify(cameraIDs), + type: QueryType.Recording, + ...partialQuery, + }, + ], + ); + + const start = new Date('2023-04-29T14:00:00'); + const end = new Date('2023-04-29T14:59:59'); + + const queries = createQueriesForRecordingsView( + cameraManager, + {}, + new Set(['camera']), + { + start: start, + end: end, + }, + ); + + expect(queries?.getQueries()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + start: start, + end: end, + }), + ]), + ); + }); +}); + +// @vitest-environment jsdom +describe('findBestMediaIndex', () => { + it('should find best media index', async () => { + const now = new Date(); + const mediaArray = [ + generateViewMedia(0, now, 60), + generateViewMedia(1, now, 120), + generateViewMedia(2, now, 10), + ]; + + expect(findBestMediaIndex(mediaArray, add(now, { seconds: 30 }))).toBe(1); + }); +}); diff --git a/tests/utils/media.test.ts b/tests/utils/media.test.ts index 3a9d6cd3..5e4aefc9 100644 --- a/tests/utils/media.test.ts +++ b/tests/utils/media.test.ts @@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended'; import { FrigateCardMediaPlayer } from '../../src/types.js'; import { FrigateCardHTMLVideoElement, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, hideMediaControlsTemporarily, playMediaMutingIfNecessary, setControlsOnVideo, @@ -108,3 +109,9 @@ describe('playMediaMutingIfNecessary', () => { expect(player.mute).toBeCalled(); }); }); + +describe('constants', () => { + it('MEDIA_LOAD_CONTROLS_HIDE_SECONDS', () => { + expect(MEDIA_LOAD_CONTROLS_HIDE_SECONDS).toBe(2); + }); +}); diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts index ffff040a..36adc42b 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/utils/menu-controller.test.ts @@ -9,6 +9,7 @@ import { FrigateCardMediaPlayer, MediaLoadedInfo, MenuButton, + ViewDisplayMode, } from '../../src/types'; import { createFrigateCardCustomAction } from '../../src/utils/action'; import { MenuButtonController } from '../../src/utils/menu-controller'; @@ -67,6 +68,7 @@ const calculateButtons = ( ); }; +// @vitest-environment jsdom describe('MenuButtonController', () => { let controller: MenuButtonController; const dynamicButton: MenuButton = { @@ -612,7 +614,10 @@ describe('MenuButtonController', () => { const cameraManager = createCameraManager(); const view = createView({ - queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), + queryResults: new MediaQueriesResults({ + results: [new ViewMedia('clip', 'camera-1')], + selectedIndex: 0, + }), }); mock(cameraManager).getMediaCapabilities.mockReturnValue( createMediaCapabilities({ canDownload: true }), @@ -640,7 +645,10 @@ describe('MenuButtonController', () => { const cameraManager = createCameraManager(); const view = createView({ - queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), + queryResults: new MediaQueriesResults({ + results: [new ViewMedia('clip', 'camera-1')], + selectedIndex: 0, + }), }); mock(cameraManager).getMediaCapabilities.mockReturnValue( createMediaCapabilities({ canDownload: true }), @@ -1084,6 +1092,27 @@ describe('MenuButtonController', () => { }); }); + describe('should have grid button when display mode is', () => { + it.each([['single' as const], ['grid' as const]])( + '%s', + (displayMode: ViewDisplayMode) => { + const view = createView({ view: 'live', displayMode: displayMode }); + expect(calculateButtons(controller, { view: view })).toContainEqual({ + icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Display mode', + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'display_mode_select', + display_mode: displayMode === 'single' ? 'grid' : 'single', + }, + }); + }, + ); + }); + it('should handle dynamic buttons', () => { const button: MenuButton = { ...dynamicButton, diff --git a/tests/utils/screenshot.test.ts b/tests/utils/screenshot.test.ts index f6fcdd37..89241a99 100644 --- a/tests/utils/screenshot.test.ts +++ b/tests/utils/screenshot.test.ts @@ -62,32 +62,30 @@ describe('generateScreenshotTitle', () => { }); it('should get title for media viewer view with id', () => { - const media = new TestViewMedia( - 'id1', - new Date('2023-06-16T18:52'), - 'clip', - 'camera-1', - ); + const media = new TestViewMedia({ + id: 'id1', + startTime: new Date('2023-06-16T18:52'), + cameraID: 'camera-1', + }); const view = createView({ view: 'media', camera: 'camera-1', - queryResults: new MediaQueriesResults([media], 0), + queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }), }); expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg'); }); it('should get title for media viewer view without id', () => { - const media = new TestViewMedia( - null, - new Date('2023-06-16T18:52'), - 'clip', - 'camera-1', - ); + const media = new TestViewMedia({ + id: null, + startTime: new Date('2023-06-16T18:52'), + cameraID: 'camera-1', + }); const view = createView({ view: 'media', camera: 'camera-1', - queryResults: new MediaQueriesResults([media], 0), + queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }), }); expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg'); diff --git a/tests/view/media-classifier.test.ts b/tests/view/media-classifier.test.ts new file mode 100644 index 00000000..af7d2e7f --- /dev/null +++ b/tests/view/media-classifier.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { ViewMediaType } from '../../src/view/media'; +import { ViewMediaClassifier } from '../../src/view/media-classifier'; +import { TestViewMedia } from '../test-utils'; + +describe('ViewMediaClassifier', () => { + describe('isEvent', () => { + it.each([ + ['clip' as const, true], + ['snapshot' as const, true], + ['recording' as const, false], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isEvent(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isRecording', () => { + it.each([ + ['clip' as const, false], + ['snapshot' as const, false], + ['recording' as const, true], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isRecording(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isClip', () => { + it.each([ + ['clip' as const, true], + ['snapshot' as const, false], + ['recording' as const, false], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isClip(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isSnapshot', () => { + it.each([ + ['clip' as const, false], + ['snapshot' as const, true], + ['recording' as const, false], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isSnapshot(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isVideo', () => { + it.each([ + ['clip' as const, true], + ['snapshot' as const, false], + ['recording' as const, true], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isVideo(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); +}); diff --git a/tests/view/media-queries-results.test.ts b/tests/view/media-queries-results.test.ts new file mode 100644 index 00000000..ce11c9e7 --- /dev/null +++ b/tests/view/media-queries-results.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ViewMedia } from '../../src/view/media'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; +import { generateViewMediaArray } from '../test-utils'; + +describe('dispatchViewContextChangeEvent', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('should function with empty results', () => { + const fakeNow = new Date('2023-08-07T20:44'); + vi.useFakeTimers(); + vi.setSystemTime(fakeNow); + + const results = new MediaQueriesResults(); + expect(results.isSupersetOf(results)).toBeFalsy(); + expect(results.getCameraIDs()).toEqual(new Set()); + expect(results.getResults()).toEqual([]); + expect(results.getResultsCount()).toEqual(0); + expect(results.hasResults()).toBeFalsy(); + expect(results.getResult(0)).toBeNull(); + expect(results.getSelectedIndex()).toBeNull(); + expect(results.getSelectedResult()).toBeNull(); + expect(results.hasSelectedResult()).toBeFalsy(); + + expect(results.resetSelectedResult()).toBe(results); + expect(results.getResultsTimestamp()).toEqual(fakeNow); + + expect(results.selectIndex(0)).toEqual(results); + expect(results.getSelectedResult()).toBeNull(); + + expect(results.selectResultIfFound((_media: ViewMedia) => true)).toEqual(results); + expect(results.getSelectedResult()).toBeNull(); + + expect(results.selectBestResult((_media: ViewMedia[]) => null)).toEqual(results); + expect(results.getSelectedResult()).toBeNull(); + }); + + it('should function with basic results', () => { + const testResults = generateViewMediaArray(); + const results = new MediaQueriesResults({ results: testResults }); + + expect(results.isSupersetOf(results)).toBeTruthy(); + expect(results.getCameraIDs()).toEqual(new Set(['kitchen', 'office'])); + expect(results.getResults()).toEqual(testResults); + expect(results.getResultsCount()).toEqual(200); + expect(results.hasResults()).toBeTruthy(); + expect(results.getResult(0)).not.toBeNull(); + expect(results.getSelectedIndex()).toBe(199); + expect(results.getSelectedResult()).not.toBeNull(); + expect(results.hasSelectedResult()).toBeTruthy(); + + expect(results.resetSelectedResult()).toBe(results); + expect(results.getSelectedResult()).toBeNull(); + + expect(results.selectIndex(100)).toEqual(results); + expect(results.getSelectedIndex()).toBe(100); + + expect( + results.selectResultIfFound( + (media: ViewMedia) => media.getID() === 'id-kitchen-42', + ), + ).toEqual(results); + expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-42'); + + expect( + results.selectBestResult((mediaArray: ViewMedia[]) => + mediaArray.findIndex((media) => media.getID() === 'id-kitchen-43'), + ), + ).toEqual(results); + expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-43'); + }); + + it('should function with camera slice', () => { + const testResults = generateViewMediaArray(); + const results = new MediaQueriesResults({ results: testResults }); + const slice = results.getSlice('office'); + expect(slice).not.toBeNull(); + expect(slice!.getResults()).toEqual( + testResults.filter((media) => media.getCameraID() === 'office'), + ); + expect(slice!.getResultsCount()).toEqual(100); + expect(slice!.hasResults()).toBeTruthy(); + expect(slice!.getResult(0)).not.toBeNull(); + expect(slice!.getResult()).toBeNull(); + expect(slice!.getSelectedIndex()).toBe(99); + expect(slice!.getSelectedResult()?.getID()).toEqual('id-office-99'); + expect(slice!.hasSelectedResult()).toBeTruthy(); + + expect(slice!.resetSelectedResult()); + expect(slice!.getSelectedResult()).toBeNull(); + + expect(slice!.selectIndex(10)); + expect(slice!.getSelectedIndex()).toBe(10); + + expect(slice!.selectIndex(10000)); + expect(slice!.getSelectedIndex()).toBe(10); + + expect(slice!.selectIndex(-10000)); + expect(slice!.getSelectedIndex()).toBe(10); + + slice!.selectResultIfFound((media: ViewMedia) => media.getID() === 'id-office-42'); + expect(slice!.getSelectedResult()?.getID()).toBe('id-office-42'); + + slice!.selectBestResult((mediaArray: ViewMedia[]) => + mediaArray.findIndex((media) => media.getID() === 'id-office-43'), + ); + expect(slice!.getSelectedResult()?.getID()).toBe('id-office-43'); + }); + + describe('should respect select approach during construction', () => { + it.each([ + ['first' as const, 0], + ['last' as const, 199], + ])('%s', async (selectApproach, expectedIndex) => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + selectApproach: selectApproach, + }); + expect(results.getSelectedIndex()).toBe(expectedIndex); + }); + }); + + it('should respect selectIndex during construction', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + selectedIndex: 42, + }); + expect(results.getSelectedIndex()).toBe(42); + }); + + it('should correctly clone a slice', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + const slice = results.getSlice('office'); + const clone = slice?.clone(); + expect(clone?.getResults()).toBe(slice?.getResults()); + expect(clone?.getSelectedIndex()).toBe(slice?.getSelectedIndex()); + }); + + it('should not get slice for non-existent camera', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + expect(results.getSlice('not-a-camera')).toBeNull(); + }); + + it('should get main slice', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + expect(results.getSlice()?.getResults()).toBe(results.getResults()); + }); + + it('should correctly clone', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + const clone = results.clone(); + expect(results.getResultsTimestamp()).toBe(clone.getResultsTimestamp()); + expect(results.getResults()).toBe(clone.getResults()); + for (const cameraID of results.getCameraIDs()) { + expect(results.getSlice(cameraID)?.getResults()).toBe( + clone.getSlice(cameraID)?.getResults(), + ); + } + }); + + it('should not getResults on invalid slice', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + expect(results.getResults('not-a-camera')).toBeNull(); + expect(results.getResultsCount('not-a-camera')).toBe(0); + expect(results.hasSelectedResult('not-a-camera')).toBeFalsy(); + }); + + it('should always demote main selection', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results + .getSlice('office') + ?.selectResultIfFound((media) => media.getID() === 'id-office-42'); + + // Verify main and office selections are as expected. + expect(results.getSelectedIndex()).toBe(199); + expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42'); + + // Select a different main result... + results?.selectResultIfFound((media) => media.getID() === 'id-office-80'); + + // ... and ensure that selection has been demoted into the camera slice. + expect(results.getSelectedResult('office')?.getID()).toBe('id-office-80'); + }); + + it('should promote camera selection', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results + .getSlice('office') + ?.selectResultIfFound((media) => media.getID() === 'id-office-42'); + + expect(results.getSelectedIndex()).toBe(199); + + results.promoteCameraSelectionToMainSelection('office'); + + expect(results.getSelectedIndex()).not.toBe(199); + expect(results.getSelectedResult()?.getID()).toBe('id-office-42'); + }); + + it('should selectBestResult via advanced selection criteria', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results.selectBestResult( + (mediaArray: ViewMedia[]) => { + const index = mediaArray.findIndex((media) => media.getID()?.endsWith('-42')); + return index < 0 ? null : index; + }, + { allCameras: true }, + ); + + expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42'); + expect(results.getSelectedResult('kitchen')?.getID()).toBe('id-kitchen-42'); + }); +}); diff --git a/tests/view/media-queries.test.ts b/tests/view/media-queries.test.ts new file mode 100644 index 00000000..9ac570f5 --- /dev/null +++ b/tests/view/media-queries.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + EventQuery, + PartialEventQuery, + PartialRecordingQuery, + QueryType, + RecordingQuery, +} from '../../src/camera-manager/types'; +import { setify } from '../../src/utils/basic'; +import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; + +describe('EventMediaQueries', () => { + const createRawEventQueries = ( + cameraIDs: string | Set, + query?: PartialEventQuery, + ): EventQuery[] => { + return [ + { + type: QueryType.Event, + cameraIDs: setify(cameraIDs), + ...query, + }, + ]; + }; + + it('should construct', () => { + const rawQueries = createRawEventQueries('office'); + const queries = new EventMediaQueries(rawQueries); + expect(queries.getQueries()).toBe(rawQueries); + }); + + it('should set', () => { + const rawQueries = createRawEventQueries('office'); + const queries = new EventMediaQueries(rawQueries); + + const newRawQueries = createRawEventQueries('kitchen'); + queries.setQueries(newRawQueries); + expect(queries.getQueries()).toBe(newRawQueries); + }); + + it('should determine if queries exist for CameraIDs', () => { + const rawQueries = createRawEventQueries(new Set(['office', 'kitchen'])); + const queries = new EventMediaQueries(rawQueries); + + expect(queries.hasQueriesForCameraIDs(new Set(['office']))).toBeTruthy(); + expect(queries.hasQueriesForCameraIDs(new Set(['office', 'kitchen']))).toBeTruthy(); + expect( + queries.hasQueriesForCameraIDs(new Set(['office', 'front_door'])), + ).toBeFalsy(); + }); + + it('should convert to clips querys', () => { + const rawQueries = createRawEventQueries('office', { hasSnapshot: true }); + const queries = new EventMediaQueries(rawQueries); + + expect(queries.convertToClipsQueries().getQueries()).toEqual([ + { + type: QueryType.Event, + cameraIDs: new Set(['office']), + hasClip: true, + }, + ]); + }); + + it('should convert when queries are null', () => { + const queries = new EventMediaQueries(); + expect(queries.convertToClipsQueries().getQueries()).toBeNull(); + }); + + it('should clone', () => { + const rawQueries = createRawEventQueries('office', { hasSnapshot: true }); + const queries = new EventMediaQueries(rawQueries); + expect(queries.clone().getQueries()).toEqual(queries.getQueries()); + }); +}); + +describe('RecordingMediaQueries', () => { + const createRawRecordingQueries = ( + cameraIDs: string | Set, + query?: PartialRecordingQuery, + ): RecordingQuery[] => { + return [ + { + type: QueryType.Recording, + cameraIDs: setify(cameraIDs), + ...query, + }, + ]; + }; + + it('should construct', () => { + const rawQueries = createRawRecordingQueries('office'); + const queries = new RecordingMediaQueries(rawQueries); + expect(queries.getQueries()).toBe(rawQueries); + }); + + it('should clone', () => { + const rawQueries = createRawRecordingQueries('office'); + const queries = new RecordingMediaQueries(rawQueries); + expect(queries.clone().getQueries()).toEqual(queries.getQueries()); + }); +}); diff --git a/tests/view/media.test.ts b/tests/view/media.test.ts new file mode 100644 index 00000000..7aaa8131 --- /dev/null +++ b/tests/view/media.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { VideoContentType, ViewMedia } from '../../src/view/media'; +import { TestViewMedia } from '../test-utils'; + +describe('ViewMedia', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('should construct', () => { + const media = new ViewMedia('clip', 'camera'); + expect(media.getCameraID()).toBe('camera'); + expect(media.getMediaType()).toBe('clip'); + expect(media.getVideoContentType()).toBeNull(); + expect(media.getID()).toBeNull(); + expect(media.getStartTime()).toBeNull(); + expect(media.getEndTime()).toBeNull(); + expect(media.getUsableEndTime()).toBeNull(); + expect(media.inProgress()).toBeNull(); + expect(media.getContentID()).toBeNull(); + expect(media.getTitle()).toBeNull(); + expect(media.getThumbnail()).toBeNull(); + expect(media.getTitle()).toBeNull(); + expect(media.includesTime(new Date())).toBeFalsy(); + expect(media.getWhere()).toBeNull(); + expect(media.setFavorite(true)).toBeUndefined(); + expect(media.isFavorite()).toBeNull(); + }); + + it('should correctly determine if a media item includes a time', () => { + const media = new TestViewMedia({ + startTime: new Date('2023-08-08T17:00:00'), + endTime: new Date('2023-08-08T17:59:59'), + }); + expect(media.includesTime(new Date('2023-08-08T17:30:30'))).toBeTruthy(); + expect(media.includesTime(new Date('2023-08-08T18:00:00'))).toBeFalsy(); + }); + + it('should correctly get usable end time for in-progress event', () => { + const media = new TestViewMedia({ + startTime: new Date('2023-08-08T17:00:00'), + inProgress: true, + }); + + vi.useFakeTimers(); + const fakeNow = new Date('2023-08-08T17:15:00'); + vi.setSystemTime(fakeNow); + + expect(media.getUsableEndTime()).toEqual(fakeNow) + }); +}); + +describe('VideoContentType', () => { + it('MP4', () => { + expect(VideoContentType.MP4).toBe('mp4'); + }); + it('HLS', () => { + expect(VideoContentType.HLS).toBe('hls'); + }); +}); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index 1d97f060..de6a1912 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it, test, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { QueryType } from '../../src/camera-manager/types'; import { ViewMedia } from '../../src/view/media'; import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View, dispatchViewContextChangeEvent } from '../../src/view/view'; -import { createView } from '../test-utils'; +import { createView, generateViewMediaArray } from '../test-utils'; // @vitest-environment jsdom describe('View Basics', () => { @@ -52,6 +52,7 @@ describe('View Basics', () => { query: new EventMediaQueries(), queryResults: new MediaQueriesResults(), context: {}, + displayMode: 'single', }); const evolved = view.evolve({ @@ -60,12 +61,14 @@ describe('View Basics', () => { query: new EventMediaQueries(), queryResults: new MediaQueriesResults(), context: {}, + displayMode: 'grid', }); expect(evolved.view).not.toBe(view.view); expect(evolved.camera).not.toBe(view.camera); expect(evolved.query).not.toBe(view.query); expect(evolved.queryResults).not.toBe(view.queryResults); expect(evolved.context).not.toBe(view.context); + expect(evolved.displayMode).not.toBe(view.displayMode); }); it('should evolve with nothing set', () => { @@ -119,6 +122,13 @@ describe('View Basics', () => { expect(view.context).toEqual({}); }); + it('should remove context property', () => { + const view = createView({ context: { live: { overrides: new Map() } } }); + + view.removeContextProperty('live', 'overrides'); + expect(view.context).toEqual({ live: {} }); + }); + it('should detect gallery views', () => { expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy(); expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy(); @@ -240,8 +250,8 @@ describe('View.isMajorMediaChange', () => { it('should consider result change as major in other view', () => { const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; - const queryResults_1 = new MediaQueriesResults(media, 0); - const queryResults_2 = new MediaQueriesResults(media, 1); + const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); + const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); expect( View.isMajorMediaChange( createView({ view: 'media', queryResults: queryResults_1 }), @@ -252,8 +262,8 @@ describe('View.isMajorMediaChange', () => { it('should not consider selected result change as major in live view', () => { const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; - const queryResults_1 = new MediaQueriesResults(media, 0); - const queryResults_2 = new MediaQueriesResults(media, 1); + const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); + const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); expect( View.isMajorMediaChange( createView({ queryResults: queryResults_1 }), @@ -282,7 +292,7 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(next.queryResults).toBe(queryResults); }); - test.each([ + it.each([ [ new EventMediaQueries([ { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, @@ -409,6 +419,76 @@ describe('View.adoptFromViewIfAppropriate', () => { View.adoptFromViewIfAppropriate(next, current); expect(next.context?.live).toEqual(current.context?.live); }); + + it('should determine if display mode is grid', () => { + expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy(); + expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy(); + expect(createView().isGrid()).toBeFalsy(); + }); + + it('should determine if view supports multiple display modes', () => { + const resultsOne = new MediaQueriesResults({ + results: generateViewMediaArray({ cameraIDs: ['office'] }), + }); + const resultsTwo = new MediaQueriesResults({ + results: generateViewMediaArray({ cameraIDs: ['office', 'kitchen'] }), + }); + + expect(createView({ view: 'live' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'live' }).hasMultipleDisplayModes(0)).toBeFalsy(); + expect(createView({ view: 'live' }).hasMultipleDisplayModes(1)).toBeFalsy(); + expect(createView({ view: 'live' }).hasMultipleDisplayModes(2)).toBeTruthy(); + + expect(createView({ view: 'media' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ view: 'media', queryResults: resultsOne }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ view: 'media', queryResults: resultsTwo }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'clip' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ view: 'clip', queryResults: resultsOne }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ view: 'clip', queryResults: resultsTwo }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'snapshot' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ + view: 'snapshot', + queryResults: resultsOne, + }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ + view: 'snapshot', + queryResults: resultsTwo, + }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'recording' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ + view: 'recording', + queryResults: resultsOne, + }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ + view: 'recording', + queryResults: resultsTwo, + }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'clips' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'snapshots' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'recordings' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'image' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'timeline' }).hasMultipleDisplayModes()).toBeFalsy(); + }); }); // @vitest-environment jsdom diff --git a/yarn.lock b/yarn.lock index 8cf2f48d..7bc02b0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1052,6 +1052,15 @@ __metadata: languageName: node linkType: hard +"@types/jquery@npm:*": + version: 3.5.16 + resolution: "@types/jquery@npm:3.5.16" + dependencies: + "@types/sizzle": "*" + checksum: 13c995f15d1c2f1d322103dc1cb0a22b95eecc3e7546f00279b8731aea21d7ec04550af40e609ee48e755d4e11bf61c25b4aa9f53df3bcbec4b8fe8e81471732 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.9": version: 7.0.11 resolution: "@types/json-schema@npm:7.0.11" @@ -1082,6 +1091,15 @@ __metadata: languageName: node linkType: hard +"@types/masonry-layout@npm:^4.2.5": + version: 4.2.5 + resolution: "@types/masonry-layout@npm:4.2.5" + dependencies: + "@types/jquery": "*" + checksum: 68961e4dd393a8d51e6e80676a814a6741c0c9175501a1a556ec6d4ef5438aa1e982248a90c7f00099fb18da9529e9f6733be5ffebdb9b2e95cd36a6adeecb40 + languageName: node + linkType: hard + "@types/node@npm:*": version: 18.11.19 resolution: "@types/node@npm:18.11.19" @@ -1112,6 +1130,13 @@ __metadata: languageName: node linkType: hard +"@types/sizzle@npm:*": + version: 2.3.3 + resolution: "@types/sizzle@npm:2.3.3" + checksum: 586a9fb1f6ff3e325e0f2cc1596a460615f0bc8a28f6e276ac9b509401039dd242fa8b34496d3a30c52f5b495873922d09a9e76c50c2ab2bcc70ba3fb9c4e160 + languageName: node + linkType: hard + "@types/trusted-types@npm:^2.0.2": version: 2.0.2 resolution: "@types/trusted-types@npm:2.0.2" @@ -2238,6 +2263,13 @@ __metadata: languageName: node linkType: hard +"desandro-matches-selector@npm:^2.0.0": + version: 2.0.2 + resolution: "desandro-matches-selector@npm:2.0.2" + checksum: 30979e6b45d7720d259d4db11ec026fcbee242deee5ec0944c265d7e386a7358ab29f7417cbc3631830d0dbe6e827940dee31f789ed865dba22880d209fc9f8e + languageName: node + linkType: hard + "diff@npm:^5.1.0": version: 5.1.0 resolution: "diff@npm:5.1.0" @@ -2864,6 +2896,13 @@ __metadata: languageName: node linkType: hard +"ev-emitter@npm:^1.0.0": + version: 1.1.1 + resolution: "ev-emitter@npm:1.1.1" + checksum: 3dd78a7620701ef8095794f611dd251bdd9023badf322b3d6fa5e10a660c50e83eec5ee5bbc7654682635cf7b8858c94e2c77e1d10bdc188a2b0593436403476 + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.4": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -2977,6 +3016,15 @@ __metadata: languageName: node linkType: hard +"fizzy-ui-utils@npm:^2.0.0": + version: 2.0.7 + resolution: "fizzy-ui-utils@npm:2.0.7" + dependencies: + desandro-matches-selector: ^2.0.0 + checksum: 001e54effa22c4f62728b90a25e8eb5477057ab53d0184e1c3231bbdfc7c34e278418fbcaf7d63c75ede823d82e61245e668ce8cba90e1c5699de960188c654f + languageName: node + linkType: hard + "flat-cache@npm:^3.0.4": version: 3.0.4 resolution: "flat-cache@npm:3.0.4" @@ -3052,6 +3100,7 @@ __metadata: "@rollup/plugin-replace": ^4.0.0 "@types/bluebird": ^3.5.36 "@types/lodash-es": ^4.17.5 + "@types/masonry-layout": ^4.2.5 "@typescript-eslint/eslint-plugin": ^5.36.2 "@typescript-eslint/parser": ^5.36.2 "@vitest/coverage-c8": ^0.29.8 @@ -3073,6 +3122,7 @@ __metadata: lit: ^2.3.1 lit-flatpickr: ^0.4.0 lodash-es: ^4.17.21 + masonry-layout: ^4.2.2 moment: ^2.29.4 prettier: ^2.6.0 propagating-hammerjs: ^2.0.1 @@ -3221,6 +3271,13 @@ __metadata: languageName: node linkType: hard +"get-size@npm:^2.0.2": + version: 2.0.3 + resolution: "get-size@npm:2.0.3" + checksum: 18d5a5fdb3f541db8b1e6ad46a5411d4bb7da3061d2ef7e544db3bbce7a13543f05c91fd40c7f68b4bc4bc035c160fed23c45514cde7d5fb18de1cce10208b19 + languageName: node + linkType: hard + "get-symbol-description@npm:^1.0.0": version: 1.0.0 resolution: "get-symbol-description@npm:1.0.0" @@ -4229,6 +4286,16 @@ __metadata: languageName: node linkType: hard +"masonry-layout@npm:^4.2.2": + version: 4.2.2 + resolution: "masonry-layout@npm:4.2.2" + dependencies: + get-size: ^2.0.2 + outlayer: ^2.1.0 + checksum: b947029b8fdfb384d17d7749d80a4f00f88d885acea04d77e1fefd36827cc2a345fd40d9ccddf720ed9323e133d8ea498125eece91d2c8517f69fd4e9e1bbdf4 + languageName: node + linkType: hard + "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -4653,6 +4720,17 @@ __metadata: languageName: node linkType: hard +"outlayer@npm:^2.1.0": + version: 2.1.1 + resolution: "outlayer@npm:2.1.1" + dependencies: + ev-emitter: ^1.0.0 + fizzy-ui-utils: ^2.0.0 + get-size: ^2.0.2 + checksum: a8b69d07bad6b498ea1956f89a4bae6d38414d81a5feeae6af06cd8206a1bf47db82f664dc3ccd13b8d2c5886a6cded4a4642d0fafcbbd80f68873b7eb9f4153 + languageName: node + linkType: hard + "p-finally@npm:^1.0.0": version: 1.0.0 resolution: "p-finally@npm:1.0.0"