diff --git a/README.md b/README.md index df616e9e..a73429d8 100644 --- a/README.md +++ b/README.md @@ -709,6 +709,7 @@ All variables listed are under a `conditions:` section. | `camera` | A list of camera ids in which this condition is satisfied. See [camera IDs](#camera-ids).| | `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode.| | `state` | A list of state conditions to compare with Home Assistant state. See below. | +| `mediaLoaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).| See the [PTZ example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. @@ -1592,7 +1593,7 @@ elements: scene.kitchen_tv_scene: icon: mdi:television title: TV! - # Show a pig icon if the card is in the live view, in fullscreen mode and light.office_main_lights is on. + # Show a pig icon if the card is in the live view, in fullscreen mode, light.office_main_lights is on and the media has been loaded. - type: custom:frigate-card-conditional elements: - type: icon @@ -1611,6 +1612,7 @@ elements: - entity: light.office_main_lights state: on state_not: off + mediaLoaded: true ``` diff --git a/src/card-condition.ts b/src/card-condition.ts index 086e9ebb..26f3a941 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -11,6 +11,7 @@ export interface ConditionState { fullscreen?: boolean; camera?: string; state?: HassEntities; + mediaLoaded?: boolean; } class ConditionStateRequestEvent extends Event { @@ -48,6 +49,10 @@ export function evaluateCondition( state.state[stateTest.entity].state !== stateTest.state_not))); } } + if (condition?.mediaLoaded !== undefined) { + result &&= + state.mediaLoaded !== undefined && condition.mediaLoaded == state.mediaLoaded; + } return result; } diff --git a/src/card.ts b/src/card.ts index 2ddf493a..e3a66b85 100644 --- a/src/card.ts +++ b/src/card.ts @@ -99,6 +99,7 @@ import { supportsFeature } from './utils/ha/update.js'; import { isValidMediaShowInfo } from './utils/media-info.js'; import { View } from './view.js'; import pkg from '../package.json'; +import { ViewContext } from 'view'; /** A note on media callbacks: * @@ -184,8 +185,9 @@ export class FrigateCard extends LitElement { // Automated refreshes of the default view. protected _updateTimerID: number | null = null; - // Information about the most recently loaded media item. - protected _mediaShowInfo: MediaShowInfo | null = null; + // Information about loaded media items. + protected _currentMediaShowInfo: MediaShowInfo | null = null; + protected _lastValidMediaShowInfo: MediaShowInfo | null = null; // Array of dynamic menu buttons to be added to menu. protected _dynamicMenuButtons: MenuButton[] = []; @@ -277,6 +279,7 @@ export class FrigateCard extends LitElement { fullscreen: screenfull.isEnabled && screenfull.isFullscreen, camera: this._view?.camera, state: this._hass?.states, + mediaLoaded: !!this._currentMediaShowInfo, }; // Update the components that need the new condition state. Passed directly @@ -331,7 +334,11 @@ export class FrigateCard extends LitElement { for (const action of actions) { // All frigate card actions will have action of 'fire-dom-event' and // styling only applies to those. - if (!action || action.action !== 'fire-dom-event' || !('frigate_card_action' in action)) { + if ( + !action || + action.action !== 'fire-dom-event' || + !('frigate_card_action' in action) + ) { continue; } const frigateCardAction = action as FrigateCardCustomAction; @@ -925,6 +932,15 @@ export class FrigateCard extends LitElement { } protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { + const changeView = (view: View): void => { + if (View.isMediaChange(this._view, view)) { + this._currentMediaShowInfo = null; + } + this._view = view; + this._generateConditionState(); + this._resetMainScroll(); + }; + if (args?.resetMessage ?? true) { this._message = null; } @@ -945,21 +961,19 @@ export class FrigateCard extends LitElement { } if (camera) { - this._view = new View({ - view: this._getConfig().view.default, - camera: camera, - }); - this._generateConditionState(); - this._resetMainScroll(); + changeView( + new View({ + view: this._getConfig().view.default, + camera: camera, + }), + ); // Restart the update timer, so the default view is refreshed at a fixed // interval from now (if so configured). this._startUpdateTimer(); } } else { - this._view = args.view; - this._generateConditionState(); - this._resetMainScroll(); + changeView(args.view); } } @@ -987,6 +1001,15 @@ export class FrigateCard extends LitElement { this._changeView({ view: e.detail }); } + /** + * Add view context to the current view. + * @param ev A ViewContext event. + */ + protected _addViewContextHandler(ev: CustomEvent): void { + this._changeView({ + view: this._view?.clone().mergeInContext(ev.detail), + }); + } /** * Called before each update. */ @@ -1592,7 +1615,7 @@ export class FrigateCard extends LitElement { */ protected _resetMainScroll(): void { // Reset the scroll on the main div to the top. - this._refMain.value?.scroll({top: 0}); + this._refMain.value?.scroll({ top: 0 }); } /** @@ -1614,19 +1637,12 @@ export class FrigateCard extends LitElement { if (!isValidMediaShowInfo(mediaShowInfo)) { return; } - let requestRefresh = false; - if ( - this._view?.isGalleryView() && - (mediaShowInfo.width != this._mediaShowInfo?.width || - mediaShowInfo.height != this._mediaShowInfo?.height) - ) { - requestRefresh = true; - } - this._mediaShowInfo = mediaShowInfo; - if (requestRefresh) { - this.requestUpdate(); - } + this._lastValidMediaShowInfo = this._currentMediaShowInfo = mediaShowInfo; + + // An update may be required to draw elements. + this._generateConditionState(); + this.requestUpdate(); } /** @@ -1702,8 +1718,8 @@ export class FrigateCard extends LitElement { } const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode; - if (aspectRatioMode == 'dynamic' && this._mediaShowInfo) { - return `${this._mediaShowInfo.width} / ${this._mediaShowInfo.height}`; + if (aspectRatioMode == 'dynamic' && this._lastValidMediaShowInfo) { + return `${this._lastValidMediaShowInfo.width} / ${this._lastValidMediaShowInfo.height}`; } const defaultAspectRatio = this._getConfig().dimensions.aspect_ratio; @@ -1776,16 +1792,14 @@ export class FrigateCard extends LitElement { style="${styleMap(cardStyle)}" @action=${(ev: CustomEvent) => this._actionHandler(ev, actions)} @ll-custom=${this._cardActionHandler.bind(this)} - @frigate-card:message=${this._messageHandler} - @frigate-card:change-view=${this._changeViewHandler} + @frigate-card:message=${this._messageHandler.bind(this)} + @frigate-card:view:change=${this._changeViewHandler.bind(this)} + @frigate-card:view:change-context=${this._addViewContextHandler.bind(this)} @frigate-card:media-show=${this._mediaShowHandler} @frigate-card:render=${() => this.requestUpdate()} > ${renderMenuAbove ? this._renderMenu() : ''} -
+
${this._cameras === undefined && !this._message ? until( (async () => { @@ -1807,7 +1821,7 @@ export class FrigateCard extends LitElement { }
${!renderMenuAbove ? this._renderMenu() : ''} - ${!this._message && this._getConfig().elements + ${this._getConfig().elements ? // Elements need to render after the main views so it can render 'on // top'. html` ) => { + @frigate-card:view:change=${(ev: CustomEvent) => { if (this._inBackground) { ev.stopPropagation(); } @@ -448,10 +451,13 @@ export class FrigateCardLiveCarousel extends LitElement { .evolve({ camera: Array.from(this.cameras.keys())[selectedCameraIndex], - // Reset the target so thumbnails will be re-fetched. + // Reset the target. target: null, childIndex: null, }) + // Don't yet fetch thumbnails (they will be fetched when the carousel + // settles). + .mergeInContext({ thumbnails: { fetch: false } }) .dispatchChangeEvent(this); } @@ -582,7 +588,11 @@ export class FrigateCardLiveCarousel extends LitElement { .label="${title ? `${localize('common.live')}: ${title}` : ''}" .titlePopupConfig=${config.controls.title} transitionEffect=${this._getTransitionEffect()} - @frigate-card:carousel:settle=${this._setViewHandler.bind(this)} + @frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} + @frigate-card:carousel:settle=${() => { + // Fetch the thumbnails after the carousel has settled. + dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true }}); + }} > ) => { this._slideResizeObserver.disconnect(); this._slideResizeObserver.observe(ev.detail.element); + + // Pass up the media-carousel select event first to allow parents to + // initialize/reset before the media info is dispatched. + dispatchFrigateCardEvent( + this, + 'media-carousel:select', + ev.detail, + ); + + // Dispatch media info. this._dispatchMediaShowInfo(); }} @frigate-card:carousel:media-show=${this._storeMediaShowInfo.bind(this)} diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 897d081c..2b6978a8 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -27,6 +27,17 @@ import { dispatchFrigateCardErrorEvent } from './message.js'; import './surround.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; +interface ThumbnailViewContext { + // Whetherr or not to fetch thumbnails. + fetch?: boolean; +} + +declare module 'view' { + interface ViewContext { + thumbnails?: ThumbnailViewContext; + } +} + @customElement('frigate-card-surround-thumbnails') export class FrigateCardSurround extends LitElement { @property({ attribute: false }) @@ -64,7 +75,8 @@ export class FrigateCardSurround extends LitElement { !this.config || this.config.mode === 'none' || this.view.target || - !this.browseMediaParams + !this.browseMediaParams || + !(this.view.context?.thumbnails?.fetch ?? true) ) { return; } @@ -147,7 +159,7 @@ export class FrigateCardSurround extends LitElement { .target=${this.view.target} .selected=${this.view.childIndex} .cameras=${this.cameras} - @frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')} + @frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent) => { // Send the view change from the source of the tap event, so the // view change will be caught by the handler above (to close the drawer). diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index ca224410..30450b60 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -282,8 +282,8 @@ export class FrigateCardThumbnail extends LitElement { view: 'timeline', target: this.target, childIndex: this.childIndex ?? null, - context: {}, }) + .removeContext('timeline') .dispatchChangeEvent(this); } else if (recording) { this.view @@ -291,7 +291,9 @@ export class FrigateCardThumbnail extends LitElement { view: 'timeline', target: null, childIndex: null, - context: { + }) + .mergeInContext({ + timeline: { window: { start: fromUnixTime(recording.start_time), end: fromUnixTime(recording.end_time), diff --git a/src/components/timeline.ts b/src/components/timeline.ts index de70feb1..fda166e7 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -21,6 +21,7 @@ import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { isEqual } from 'lodash-es'; +import { ViewContext } from 'view'; import { DataSet } from 'vis-data/esnext'; import { DataGroupCollectionType, @@ -64,7 +65,7 @@ import { isTrueMedia, multipleBrowseMediaQuery, } from '../utils/ha/browse-media'; -import { View, ViewContext } from '../view'; +import { View } from '../view'; import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js'; import './surround-thumbnails.js'; @@ -81,7 +82,7 @@ interface FrigateCardTimelineItem extends TimelineItem { source?: FrigateBrowseMediaSource; } -interface TimelineViewContext extends ViewContext { +interface TimelineViewContext { // The selected timeline window. window?: TimelineWindow; @@ -89,6 +90,12 @@ interface TimelineViewContext extends ViewContext { dateFetch?: Date; } +declare module 'view' { + interface ViewContext { + timeline?: TimelineViewContext; + } +} + type TimelineMediaType = 'all' | 'clips' | 'snapshots'; interface CameraRecordings { @@ -867,8 +874,8 @@ export class FrigateCardTimelineCore extends LitElement { ?.evolve({ target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, - context: this._generateViewContext(true), }) + .mergeInContext(this._generateTimelineContext(true)) .dispatchChangeEvent(this); } }); @@ -1174,7 +1181,7 @@ export class FrigateCardTimelineCore extends LitElement { // Regenerate the thumbnails after the selection, to allow the new selection // to be in the generated view. - const context = this.view.context as TimelineViewContext | null; + const context = this.view.context?.timeline; const timelineWindow = this._timeline.getWindow(); if (context?.window) { @@ -1222,8 +1229,8 @@ export class FrigateCardTimelineCore extends LitElement { ?.evolve({ target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, - context: this._generateViewContext(false), }) + .mergeInContext(this._generateTimelineContext(false)) .dispatchChangeEvent(this); } } @@ -1234,9 +1241,10 @@ export class FrigateCardTimelineCore extends LitElement { * the window is preserved if it is already in the context. * @returns The TimelineViewContext object. */ - protected _generateViewContext(addWindow: boolean): TimelineViewContext { - const currentContext = this.view?.context as TimelineViewContext | undefined; - const newContext: TimelineViewContext = {}; + protected _generateTimelineContext(addWindow: boolean): ViewContext { + const currentContext = this.view?.context?.timeline; + const newContext: TimelineViewContext = {} + if (addWindow && this._timeline) { newContext.window = this._timeline.getWindow(); } else if (currentContext?.window) { @@ -1245,7 +1253,7 @@ export class FrigateCardTimelineCore extends LitElement { if (this._data.lastFetchDate) { newContext.dateFetch = this._data.lastFetchDate; } - return newContext || null; + return Object.keys(newContext) ? {timeline: newContext} : {}; } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index b193f29e..3a932472 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -618,7 +618,7 @@ export class FrigateCardViewerCarousel extends LitElement { .label="${this.view.media.title}" .titlePopupConfig=${this.viewerConfig?.controls.title} transitionEffect=${this._getTransitionEffect()} - @frigate-card:carousel:select=${this._setViewHandler.bind(this)} + @frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} @frigate-card:media-show=${this._recordingSeekHandler.bind(this)} > ; diff --git a/src/view.ts b/src/view.ts index 8f2ee4bd..8b5cc665 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,15 +1,13 @@ +import { ViewContext } from 'view'; import { FrigateBrowseMediaSource, FrigateCardUserSpecifiedView, FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, - FRIGATE_CARD_VIEW_DEFAULT + FRIGATE_CARD_VIEW_DEFAULT, } from './types.js'; import { dispatchFrigateCardEvent } from './utils/basic.js'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ViewContext {} - export interface ViewEvolveParameters { view?: FrigateCardView; camera?: string; @@ -63,6 +61,26 @@ export class View { : FRIGATE_CARD_VIEW_DEFAULT; } + /** + * Detect if a view change represents a major "media change" for the given + * view. + * @param prev The previous view. + * @param curr The current view. + * @returns True if the view change is a real media change. + */ + public static isMediaChange(prev?: View, curr?: View): boolean { + return ( + !prev || + !curr || + prev.view !== curr.view || + prev.camera !== curr.camera || + // When in the live view, the target/childIndex are the events that + // happened in the past -- not reflective of the actual live media viewer. + (curr.view !== 'live' && + (prev.target !== curr.target || prev.childIndex !== curr.childIndex)) + ); + } + /** * Clone a view. */ @@ -96,6 +114,28 @@ export class View { }); } + /** + * Merge view contexts. + * @param context The context to merge in. + * @returns This view. + */ + public mergeInContext(context: ViewContext): View { + this.context = { ...this.context, ...context }; + return this; + } + + /** + * Remove a context key. + * @param key The key to remove. + * @returns This view. + */ + public removeContext(key: keyof ViewContext): View { + if (this.context) { + delete(this.context[key]); + } + return this; + } + /** * Determine if current view matches a named view. */ @@ -168,6 +208,18 @@ export class View { * @param target The target dispatching the event. */ public dispatchChangeEvent(target: EventTarget): void { - dispatchFrigateCardEvent(target, 'change-view', this); + dispatchFrigateCardEvent(target, 'view:change', this); } } + +/** + * Dispatch an event to change the view context. + * @param target The EventTarget to send the event from. + * @param context The context to change. + */ +export const dispatchViewContextChangeEvent = ( + target: EventTarget, + context: ViewContext, +): void => { + dispatchFrigateCardEvent(target, 'view:change-context', context); +};