diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index b4ae2228..f096f40f 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -134,6 +134,9 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie // progress. return !this.getEndTime(); } + public getVideoContentType(): VideoContentType | null { + return VideoContentType.HLS; + } public getContentID(): string | null { return this._contentID; } diff --git a/src/card.ts b/src/card.ts index 57904311..906b7e5e 100644 --- a/src/card.ts +++ b/src/card.ts @@ -5,63 +5,67 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS, + unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import cloneDeep from 'lodash-es/cloneDeep'; +import isEqual from 'lodash-es/isEqual'; +import merge from 'lodash-es/merge'; import throttle from 'lodash-es/throttle'; import screenfull from 'screenfull'; +import { ViewContext } from 'view'; +import 'web-dialog'; import { z } from 'zod'; +import pkg from '../package.json'; import { actionHandler } from './action-handler-directive.js'; +import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; +import { CameraManager } from './camera-manager/manager.js'; import { CardConditionManager, ConditionState, conditionStateRequestHandler, - getOverriddenConfig, - getOverridesByKey, + getOverriddenConfig } from './card-condition.js'; import './components/elements.js'; import { FrigateCardElements } from './components/elements.js'; -import type { FrigateCardImage } from './components/image.js'; -import type { FrigateCardLive } from './components/live/live.js'; import './components/menu.js'; import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js'; import './components/message.js'; import { renderMessage, renderProgressIndicator } from './components/message.js'; import './components/thumbnail-carousel.js'; +import './components/views.js'; +import { FrigateCardViews } from './components/views.js'; import { isConfigUpgradeable } from './config-mgmt.js'; import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js'; import { getLanguage, loadLanguages, localize } from './localize/localize.js'; +import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; import cardStyle from './scss/card.scss'; import { Actions, ActionType, CameraConfig, CardWideConfig, - ExtendedHomeAssistant, - FRIGATE_CARD_VIEW_DEFAULT, - FRIGATE_CARD_VIEWS_USER_SPECIFIED, - FrigateCardConfig, + ExtendedHomeAssistant, FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, FrigateCardError, - FrigateCardView, - MediaLoadedInfo, - MenuButton, - MESSAGE_TYPE_PRIORITIES, - Message, - RawFrigateCardConfig, + FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, FRIGATE_CARD_VIEW_DEFAULT, MediaLoadedInfo, + MenuButton, Message, MESSAGE_TYPE_PRIORITIES, RawFrigateCardConfig } from './types.js'; import { convertActionToFrigateCardCustomAction, createFrigateCardCustomAction, frigateCardHandleAction, frigateCardHasAction, - getActionConfigGivenAction, + getActionConfigGivenAction } from './utils/action.js'; import { errorToConsole } from './utils/basic.js'; +import { getAllDependentCameras } from './utils/camera.js'; +import { log } from './utils/debug.js'; +import { downloadMedia } from './utils/download.js'; import { getEntityIcon, getEntityTitle, @@ -69,30 +73,18 @@ import { isCardInPanel, isHassDifferent, isTriggeredState, - sideLoadHomeAssistantElements, + sideLoadHomeAssistantElements } from './utils/ha'; import { DeviceList, getAllDevices } from './utils/ha/device-registry.js'; +import { EntityCache } from './utils/ha/entity-registry/cache.js'; +import { EntityRegistryManager } from './utils/ha/entity-registry/index.js'; +import { Entity } from './utils/ha/entity-registry/types.js'; import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; -import { isValidMediaLoadedInfo } from './utils/media-info.js'; -import { View } from './view/view.js'; -import pkg from '../package.json'; -import { ViewContext } from 'view'; -import { CameraManager } from './camera-manager/manager.js'; -import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; -import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; -import { log } from './utils/debug.js'; -import { EntityRegistryManager } from './utils/ha/entity-registry/index.js'; -import { EntityCache } from './utils/ha/entity-registry/cache.js'; -import { Entity } from './utils/ha/entity-registry/types.js'; -import { getAllDependentCameras } from './utils/camera.js'; -import cloneDeep from 'lodash-es/cloneDeep'; -import isEqual from 'lodash-es/isEqual'; -import merge from 'lodash-es/merge'; import { FrigateCardInitializer } from './utils/initializer.js'; -import 'web-dialog'; -import { downloadMedia } from './utils/download.js'; +import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { getActionsFromQueryString } from './utils/querystring.js'; +import { View } from './view/view.js'; /** A note on media callbacks: * @@ -185,8 +177,7 @@ class FrigateCard extends LitElement { protected _refMenu: Ref = createRef(); protected _refMain: Ref = createRef(); protected _refElements: Ref = createRef(); - protected _refImage: Ref = createRef(); - protected _refLive: Ref = createRef(); + protected _refViews: Ref = createRef(); // user interaction timer ("screensaver" functionality, return to default // view after user interaction). @@ -246,8 +237,8 @@ class FrigateCard extends LitElement { if (this._refElements.value) { this._refElements.value.hass = this._hass; } - if (this._refImage.value) { - this._refImage.value.hass = this._hass; + if (this._refViews.value) { + this._refViews.value.hass = this._hass; } } @@ -311,8 +302,8 @@ class FrigateCard extends LitElement { // to them to avoid the performance hit of a entire card re-render (esp. // when using card-mod). // https://github.com/dermotduffy/frigate-hass-card/issues/678 - if (this._refLive.value) { - this._refLive.value.conditionState = this._conditionState; + if (this._refViews.value) { + this._refViews.value.conditionState = this._conditionState; } if (this._refElements.value) { this._refElements.value.conditionState = this._conditionState; @@ -947,18 +938,6 @@ class FrigateCard extends LitElement { this._initializeBackground(); - if (this._view?.is('live')) { - import('./components/live/live.js'); - } else if (this._view?.isGalleryView()) { - import('./components/gallery.js'); - } else if (this._view?.isViewerView()) { - import('./components/viewer.js'); - } else if (this._view?.is('image')) { - import('./components/image.js'); - } else if (this._view?.is('timeline')) { - import('./components/timeline.js'); - } - if (changedProps.has('_view')) { this._setPropertiesForExpandedMode(); } @@ -2013,7 +1992,18 @@ class FrigateCard extends LitElement { ? renderProgressIndicator({ cardWideConfig: this._cardWideConfig }) : // Always want to call render even if there's a message, to // ensure live preload is always present (even if not displayed). - this._render()} + html``} ${ // Keep message rendering to last to show messages that may have been // generated during the render. @@ -2044,92 +2034,6 @@ class FrigateCard extends LitElement { `); } - /** - * Sub-render method for the card. - */ - protected _render(): TemplateResult | void { - const cameraConfig = this._getSelectedCameraConfig(); - - if (!this._hass || !this._view || !cameraConfig) { - return html``; - } - - // Render but hide the live view if there's a message, or if it's preload - // mode and the view is not live. - const liveClasses = { - hidden: - !!this._message || (this._getConfig().live.preload && !this._view.is('live')), - }; - - return html` - ${!this._message && this._view.is('image') - ? html` - ` - : ``} - ${!this._message && this._view.isGalleryView() - ? html` - ` - : ``} - ${!this._message && this._view.isViewerView() - ? html` - ` - : ``} - ${!this._message && this._view.is('timeline') - ? html` - ` - : ``} - ${ - // Note: Subtle difference in condition below vs the other views in order - // to always render the live view for live.preload mode. - - // Note: uses the underlying _config rather than the - // overriden config (via getConfig), as it does it's own overriding as - // part of the camera carousel. - this._getConfig().live.preload || (!this._message && this._view.is('live')) - ? html` - - - ` - : `` - } - `; - } - protected firstUpdated(): void { // Execute query string actions after first render is complete. getActionsFromQueryString().forEach((action) => this._cardActionHandler(action)); diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 4dd90032..a45264b3 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -75,17 +75,17 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla return this._player?.video?.play(); } - public pause(): void { + public async pause(): Promise { this._player?.video?.pause(); } - public mute(): void { + public async mute(): Promise { if (this._player?.video) { this._player.video.muted = true; } } - public unmute(): void { + public async unmute(): Promise { if (this._player?.video) { this._player.video.muted = false; } @@ -95,7 +95,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla return this._player?.video.muted ?? true; } - public seek(seconds: number): void { + public async seek(seconds: number): Promise { if (this._player?.video) { this._player.video.currentTime = seconds; } diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 6162c195..1d8e5aee 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -23,15 +23,15 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla return this._playerRef.value?.play(); } - public pause(): void { + public async pause(): Promise { this._playerRef.value?.pause(); } - public mute(): void { + public async mute(): Promise { this._playerRef.value?.mute(); } - public unmute(): void { + public async unmute(): Promise { this._playerRef.value?.unmute(); } @@ -39,7 +39,7 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla return this._playerRef.value?.isMuted() ?? true; } - public seek(seconds: number): void { + public async seek(seconds: number): Promise { this._playerRef.value?.seek(seconds); } diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 236a5596..a8e655d2 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -21,15 +21,15 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia this._playing = true; } - public pause(): void { + public async pause(): Promise { this._playing = false; } - public mute(): void { + public async mute(): Promise { // Not implemented. } - public unmute(): void { + public async unmute(): Promise { // Not implemented. } @@ -38,7 +38,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public seek(_seconds: number): void { + public async seek(_seconds: number): Promise { // Not implemented. } @@ -51,7 +51,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia return html` { this._jsmpegVideoPlayer?.stop(); } - public mute(): void { + public async mute(): Promise { const player = this._jsmpegVideoPlayer?.player; if (player) { player.volume = 0; } } - public unmute(): void { + public async unmute(): Promise { const player = this._jsmpegVideoPlayer?.player; if (player) { player.volume = 1; @@ -66,7 +66,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public seek(_seconds: number): void { + public async seek(_seconds: number): Promise { // JSMPEG does not support seeking. } diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index f1fce118..6cd0f4a6 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -44,18 +44,18 @@ export class FrigateCardLiveWebRTCCard return this._getPlayer()?.play(); } - public pause(): void { + public async pause(): Promise { this._getPlayer()?.pause(); } - public mute(): void { + public async mute(): Promise { const player = this._getPlayer(); if (player) { player.muted = true; } } - public unmute(): void { + public async unmute(): Promise { const player = this._getPlayer(); if (player) { player.muted = false; @@ -66,7 +66,7 @@ export class FrigateCardLiveWebRTCCard return this._getPlayer()?.muted ?? true; } - public seek(seconds: number): void { + public async seek(seconds: number): Promise { const player = this._getPlayer(); if (player) { player.currentTime = seconds; @@ -78,7 +78,7 @@ export class FrigateCardLiveWebRTCCard // Reset the player when reconnected to the DOM. // https://github.com/dermotduffy/frigate-hass-card/issues/996 - this.requestUpdate(); + this.requestUpdate(); } /** diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 3f6a7b9b..2f5ef65a 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -69,6 +69,11 @@ declare module 'view' { } } +interface LastMediaLoadedInfo { + mediaLoadedInfo: MediaLoadedInfo; + source: EventTarget; +} + const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; /** @@ -141,9 +146,11 @@ export class FrigateCardLive extends LitElement { // foreground and background (in preload mode). protected _intersectionObserver: IntersectionObserver; - // MediaLoadedInfo object and message from the underlying live object. In the - // case of pre-loading these may be propagated upwards later. - protected _backgroundMediaLoadedInfo: MediaLoadedInfo | null = null; + // MediaLoadedInfo object and target from the underlying live object. In the + // case of pre-loading these may be propagated later (from the original + // source). + protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null; + protected _messageReceivedPostRender = false; protected _renderKey = 0; @@ -164,12 +171,17 @@ export class FrigateCardLive extends LitElement { if ( !this._inBackground && !this._messageReceivedPostRender && - this._backgroundMediaLoadedInfo + this._lastMediaLoadedInfo ) { // If this isn't being rendered in the background, the last render did not // generate a message and there's a saved MediaInfo, dispatch it upwards. - dispatchExistingMediaLoadedInfoAsEvent(this, this._backgroundMediaLoadedInfo); - this._backgroundMediaLoadedInfo = null; + dispatchExistingMediaLoadedInfoAsEvent( + // Specifically dispatch the event "where it came from", as otherwise + // the intermediate layers (e.g. media-carousel which controls the title + // popups) will not re-receive the events. + this._lastMediaLoadedInfo.source, + this._lastMediaLoadedInfo.mediaLoadedInfo, + ); } // Trigger a re-render which may be necessary if the prior render resulted @@ -217,18 +229,10 @@ export class FrigateCardLive extends LitElement { return; } - const config = getOverriddenConfig( - this.liveConfig, - this.liveOverrides, - this.conditionState, - ) as LiveConfig; - // Notes: // - See use of liveConfig and not config below -- the carousel will // independently override the liveConfig to reflect the camera in the // carousel (not necessarily the selected camera). - // - Fetching of thumbnails is disabled as long as live view is the - // background. // - Various events are captured to prevent them propagating upwards if the // card is in the background. // - The entire returned template is keyed to allow for the whole template @@ -236,34 +240,7 @@ export class FrigateCardLive extends LitElement { // is received when the card is in the background). const result = html`${keyed( this._renderKey, - html`) => { - this._renderKey++; - this._messageReceivedPostRender = true; - if (this._inBackground) { - ev.stopPropagation(); - } - }} - @frigate-card:media:loaded=${(ev: CustomEvent) => { - if (this._inBackground) { - this._backgroundMediaLoadedInfo = ev.detail; - ev.stopPropagation(); - } - }} - @frigate-card:view:change=${(ev: CustomEvent) => { - if (this._inBackground) { - ev.stopPropagation(); - } - }} - > + html` ) => { + this._renderKey++; + this._messageReceivedPostRender = true; + if (this._inBackground) { + ev.stopPropagation(); + } + }} + @frigate-card:media:loaded=${(ev: CustomEvent) => { + this._lastMediaLoadedInfo = { + source: ev.composedPath()[0], + mediaLoadedInfo: ev.detail, + }; + if (this._inBackground) { + ev.stopPropagation(); + } + }} + @frigate-card:view:change=${(ev: CustomEvent) => { + if (this._inBackground) { + ev.stopPropagation(); + } + }} > - `, + `, )}`; this._messageReceivedPostRender = false; @@ -327,18 +325,22 @@ export class FrigateCardLiveCarousel extends LitElement { updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - const frigateCardMediaCarousel = this._refMediaCarousel.value; - - if (frigateCardMediaCarousel && changedProperties.has('inBackground')) { - // If this has changed to be in the background (i.e. preloaded but not - // visible) take the appropriate play/pause/mute/unmute actions. - if (this.inBackground) { - frigateCardMediaCarousel.autoPause(); - frigateCardMediaCarousel.autoMute(); - } else { - frigateCardMediaCarousel.autoPlay(); - frigateCardMediaCarousel.autoUnmute(); - } + if (changedProperties.has('inBackground')) { + this.updateComplete.then(async () => { + const frigateCardMediaCarousel = this._refMediaCarousel.value; + if (frigateCardMediaCarousel) { + await frigateCardMediaCarousel.updateComplete; + // If this has changed to be in the background (i.e. preloaded but not + // visible) take the appropriate play/pause/mute/unmute actions. + if (this.inBackground) { + frigateCardMediaCarousel.autoPause(); + frigateCardMediaCarousel.autoMute(); + } else { + frigateCardMediaCarousel.autoPlay(); + frigateCardMediaCarousel.autoUnmute(); + } + } + }); } } @@ -722,21 +724,41 @@ export class FrigateCardLiveProvider @state() protected _isVideoMediaLoaded = false; - protected _refProvider: Ref = createRef(); + protected _refProvider: Ref = createRef(); + + // A note on dynamic imports: + // + // We gather the dynamic live provider import promises and do not consider the + // update of the element complete until these imports have returned. Without + // this behavior calls to the media methods (e.g. `mute()`) may throw if the + // underlying code is not yet loaded. + // + // Test case: A card with a non-live view, but live pre-loaded, attempts to + // call mute() when the element first renders in the + // background. These calls fail without waiting for loading here. + protected _importPromises: Promise[] = []; public async play(): Promise { - playMediaMutingIfNecessary(this, this._refProvider.value); + await this.updateComplete; + await this._refProvider.value?.updateComplete; + await playMediaMutingIfNecessary(this, this._refProvider.value); } - public pause(): void { + public async pause(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; this._refProvider.value?.pause(); } - public mute(): void { + public async mute(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; this._refProvider.value?.mute(); } - public unmute(): void { + public async unmute(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; this._refProvider.value?.unmute(); } @@ -744,7 +766,9 @@ export class FrigateCardLiveProvider return this._refProvider.value?.isMuted() ?? true; } - public seek(seconds: number): void { + public async seek(seconds: number): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; this._refProvider.value?.seek(seconds); } @@ -813,25 +837,34 @@ export class FrigateCardLiveProvider if (changedProps.has('liveConfig')) { updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout); if (this.liveConfig?.show_image_during_load) { - import('./live-image.js'); + this._importPromises.push(import('./live-image.js')); } } if (changedProps.has('cameraConfig')) { const provider = this._getResolvedProvider(); if (provider === 'jsmpeg') { - import('./live-jsmpeg.js'); + this._importPromises.push(import('./live-jsmpeg.js')); } else if (provider === 'ha') { - import('./live-ha.js'); + this._importPromises.push(import('./live-ha.js')); } else if (provider === 'webrtc-card') { - import('./live-webrtc-card.js'); + this._importPromises.push(import('./live-webrtc-card.js')); } else if (provider === 'image') { - import('./live-image.js'); + this._importPromises.push(import('./live-image.js')); } else if (provider === 'go2rtc') { - import('./live-go2rtc.js'); + this._importPromises.push(import('./live-go2rtc.js')); } } } + override async getUpdateComplete(): Promise { + // See 'A note on dynamic imports' above for explanation of why this is + // necessary. + const result = await super.getUpdateComplete(); + await Promise.all(this._importPromises); + this._importPromises = []; + return result; + } + /** * Master render method. * @returns A rendered template. diff --git a/src/components/surround.ts b/src/components/surround.ts index 7daf8c4b..79f69242 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -49,9 +49,6 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public timelineConfig?: MiniTimelineControlConfig; - @property({ attribute: false }) - public inBackground?: boolean; - // If fetchMedia is not specified, no fetching is done. @property({ attribute: false, hasChanged: contentsChanged }) public fetchMedia?: ClipsOrSnapshotsOrAll; @@ -75,7 +72,6 @@ export class FrigateCardSurround extends LitElement { !this.cameraManager || !this.cardWideConfig || !this.fetchMedia || - this.inBackground || !this.hass || !this.view || this.view.query || @@ -131,9 +127,7 @@ export class FrigateCardSurround extends LitElement { // do so if properties relevant to the request have changed (as per their // hasChanged). if ( - ['view', 'fetch', 'browseMediaParams', 'inBackground'].some((prop) => - changedProperties.has(prop), - ) + ['view', 'fetch', 'browseMediaParams'].some((prop) => changedProperties.has(prop)) ) { this._fetchMedia(); } @@ -162,7 +156,7 @@ export class FrigateCardSurround extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.thumbnailConfig) { + if (!this.hass || !this.view) { return; } @@ -183,9 +177,7 @@ export class FrigateCardSurround extends LitElement { @frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')} @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} > - ${this.thumbnailConfig && - this.thumbnailConfig.mode !== 'none' && - !this.inBackground + ${this.thumbnailConfig && this.thumbnailConfig.mode !== 'none' ? html` ` : ''} - ${this.timelineConfig?.mode && - this.timelineConfig.mode !== 'none' && - !this.inBackground + ${this.timelineConfig && this.timelineConfig.mode !== 'none' ? html` ${this._shouldSupportSeeking() ? html` { - this._locked = !this._locked; + this._panBehavior = + this._panBehavior === 'pan' + ? 'seek' + : this._panBehavior === 'seek' + ? 'seek-in-media' + : 'pan'; }} - aria-label="${lockTitle}" - title="${lockTitle}" + aria-label="${panTitle}" + title="${panTitle}" > ` : ''} @@ -340,9 +356,10 @@ export class FrigateCardTimelineCore extends LitElement { } if ( + this._shouldSupportSeeking() && this._timeline && properties.byUser && - // Do not adjust select children or seek during zoom events. + // Do not adjust select/seek media during zoom events. properties.event.type !== 'wheel' && properties.event.additionalEvent !== 'pinchin' && properties.event.additionalEvent !== 'pinchout' @@ -365,13 +382,7 @@ export class FrigateCardTimelineCore extends LitElement { } protected _shouldSupportSeeking(): boolean { - const cameraIDs = this._getTimelineCameraIDs(); - if (!this._timeline || !cameraIDs) { - return false; - } - - const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); - return (this.view?.isViewerView() && capabilities?.canSeek) ?? false; + return this.mini; } /** @@ -385,8 +396,8 @@ export class FrigateCardTimelineCore extends LitElement { const targetBarOn = this._shouldSupportSeeking() && - (!this._locked || - (this.mini && + (this._panBehavior === 'seek' || + (this._panBehavior === 'seek-in-media' && this._timeline.getSelection().some((id) => { const item = this._timelineSource?.dataset?.get(id); return ( @@ -439,6 +450,7 @@ export class FrigateCardTimelineCore extends LitElement { !this.view || !this.hass || !this.cameraManager || + this._panBehavior === 'pan' || // Skip range changes that do not have hammerjs pan directions associated // with them, as these outliers cause media matching issues below. !properties.event.additionalEvent @@ -446,37 +458,38 @@ export class FrigateCardTimelineCore extends LitElement { return; } - const canSeek = !!this.view?.isViewerView(); - const newResults = this._locked - ? null - : results - .clone() - .resetSelectedResult() - .selectBestResult((media) => - findClosestMediaIndex( - media, - targetTime, - properties.event.additionalEvent === 'panright' ? 'end' : 'start', - ), - ); + const canSeek = this._shouldSupportSeeking(); + const newResults = + this._panBehavior === 'seek-in-media' + ? null + : results + .clone() + .resetSelectedResult() + .selectBestResult((media) => + findClosestMediaIndex( + media, + targetTime, + properties.event.additionalEvent === 'panright' ? 'end' : 'start', + ), + ); - if ( - canSeek || - (newResults && - newResults.hasSelectedResult() && - newResults.getResult() !== results.getResult()) - ) { - this.view - .evolve({ - ...(newResults && - newResults.hasSelectedResult() && { queryResults: newResults }), - }) // Whether or not to set the timeline window. - .mergeInContext({ - ...(canSeek && { mediaViewer: { seek: targetTime } }), - ...this._setWindowInContext(properties), - }) - .dispatchChangeEvent(this); - } + const desiredView: FrigateCardView = this.mini + ? targetTime >= new Date() + ? 'live' + : 'media' + : this.view.view; + + this.view + .evolve({ + view: desiredView, + ...(newResults && + newResults.hasSelectedResult() && { queryResults: newResults }), + }) // Whether or not to set the timeline window. + .mergeInContext({ + ...(canSeek && { mediaViewer: { seek: targetTime } }), + ...this._getTimelineContext(properties), + }) + .dispatchChangeEvent(this); } /** @@ -667,7 +680,7 @@ export class FrigateCardTimelineCore extends LitElement { // 'flicker' for the user as the viewer reloads all the media). const newResults = newView?.queryResults; if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { - newView?.mergeInContext(this._setWindowInContext())?.dispatchChangeEvent(this); + newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); } } } @@ -948,6 +961,10 @@ export class FrigateCardTimelineCore extends LitElement { : null; const context = this.view.context?.timeline; + if (context && context.panBehavior) { + this._panBehavior = context.panBehavior; + } + if (context && context.window) { desiredWindow = context.window; } else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { @@ -1021,7 +1038,7 @@ export class FrigateCardTimelineCore extends LitElement { !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { (await this._createViewWithEventMediaQuery(freshMediaQuery)) - ?.mergeInContext(this._setWindowInContext(desiredWindow)) + ?.mergeInContext(this._getTimelineContext(desiredWindow)) .dispatchChangeEvent(this); } } @@ -1046,11 +1063,12 @@ export class FrigateCardTimelineCore extends LitElement { * Generate the context for timeline views. * @returns The TimelineViewContext object. */ - protected _setWindowInContext(window?: TimelineWindow): ViewContext { + protected _getTimelineContext(window?: TimelineWindow): ViewContext { const newWindow = window ?? this._timeline?.getWindow(); return { timeline: { ...this.view?.context?.timeline, + panBehavior: this._panBehavior, ...(newWindow && { window: newWindow }), }, }; diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 2572eb70..f70a7db1 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -7,11 +7,6 @@ import { View } from '../view/view'; import './surround.js'; import './timeline-core.js'; -// This file is kept separate from timeline-core.ts to avoid a circular dependency: -// FrigateCardTimeline -> -// FrigateCardSurround -> -// FrigateCardTimelineCore - @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) @@ -38,12 +33,7 @@ export class FrigateCardTimeline extends LitElement { return html``; } - return html` + return html` - `; + `; } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 309c6d78..3a8f8d72 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -142,14 +142,7 @@ export class FrigateCardViewer extends LitElement { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } - return html` + return html` - `; + `; } /** @@ -565,17 +558,17 @@ export class FrigateCardViewerProvider protected _refVideoProvider: Ref = createRef(); public async play(): Promise { - playMediaMutingIfNecessary( + await playMediaMutingIfNecessary( this, this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value, ); } - public pause(): void { + public async pause(): Promise { (this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause(); } - public mute(): void { + public async mute(): Promise { if (this._refFrigateCardMediaPlayer.value) { this._refFrigateCardMediaPlayer.value?.mute(); } else if (this._refVideoProvider.value) { @@ -583,7 +576,7 @@ export class FrigateCardViewerProvider } } - public unmute(): void { + public async unmute(): Promise { if (this._refFrigateCardMediaPlayer.value) { this._refFrigateCardMediaPlayer.value?.mute(); } else if (this._refVideoProvider.value) { @@ -600,7 +593,7 @@ export class FrigateCardViewerProvider return true; } - public seek(seconds: number): void { + public async seek(seconds: number): Promise { if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value.seek(seconds); } else if (this._refVideoProvider.value) { diff --git a/src/components/views.ts b/src/components/views.ts new file mode 100644 index 00000000..13a60204 --- /dev/null +++ b/src/components/views.ts @@ -0,0 +1,224 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { CameraManager } from '../camera-manager/manager.js'; +import { ConditionState, getOverridesByKey } from '../card-condition'; +import viewsStyle from '../scss/views.scss'; +import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { View } from '../view/view.js'; +import './surround.js'; + +@customElement('frigate-card-views') +export class FrigateCardViews extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public config?: FrigateCardConfig; + + @property({ attribute: false }) + public nonOverriddenConfig?: FrigateCardConfig; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public resolvedMediaCache?: ResolvedMediaCache; + + @property({ attribute: false }) + public conditionState?: ConditionState; + + @property({ attribute: false }) + public cameras?: ConditionState; + + @property({ attribute: false }) + public hide?: boolean; + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('view') || changedProps.has('config')) { + if (this.view?.is('live') || this._shouldLivePreload()) { + import('./live/live.js'); + } + if (this.view?.isGalleryView()) { + import('./gallery.js'); + } else if (this.view?.isViewerView()) { + import('./viewer.js'); + } else if (this.view?.is('image')) { + import('./image.js'); + } else if (this.view?.is('timeline')) { + import('./timeline.js'); + } + } + + if (changedProps.has('hide')) { + if (this.hide) { + this.setAttribute('hidden', ''); + } else { + this.removeAttribute('hidden'); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldUpdate(_: PropertyValues): boolean { + // Future: Updates to `hass` and `conditionState` here will be frequent. + // Throttling here may be necessary if users report performance degradation + // > v5.0.0-beta1 . + // + // These updates are necessary in these cases: + // - conditionState: Required to let `frigate-card-live` calculate its own + // overrides. + // - hass: Required for anything that needs to sign URLs. Of note is + // anything that renders an image (e.g. a thumbnail -- almost everything, + // or the main `frigate-card-image` view). + // + // It should instead be possible to pass conditionState to live only (every + // update required), and pass hass only once / 5 minutes (see + // HASS_REJECTION_CUTOFF_MS). + return true; + } + + protected _shouldLivePreload(): boolean { + return !!this.config?.live.preload; + } + + protected render(): TemplateResult | void { + // Only essential items should be added to the below list, since we want the + // overall views pane to render in ~almost all cases (e.g. for a camera + // initialization error to display, `view` and `cameraConfig` may both be + // undefined, but we still want to render). + if (!this.hass || !this.config || !this.nonOverriddenConfig) { + return html``; + } + + // Render but hide the live view if there's a message, or if it's preload + // mode and the view is not live. + const liveClasses = { + hidden: this._shouldLivePreload() && !this.view?.is('live'), + }; + const overallClasses = { + hidden: !!this.hide, + }; + + const thumbnailConfig = this.view?.is('live') + ? this.config.live.controls.thumbnails + : this.view?.isViewerView() + ? this.config.media_viewer.controls.thumbnails + : this.view?.is('timeline') + ? this.config.timeline.controls.thumbnails + : undefined; + + const miniTimelineConfig = this.view?.is('live') + ? this.config.live.controls.timeline + : this.view?.isViewerView() + ? this.config.media_viewer.controls.timeline + : undefined; + + const cameraConfig = this.view + ? this.cameraManager?.getStore().getCameraConfig(this.view.camera) ?? null + : null; + + return html` + ${!this.hide && this.view?.is('image') && cameraConfig + ? html` + ` + : ``} + ${!this.hide && this.view?.isGalleryView() + ? html` + ` + : ``} + ${!this.hide && this.view?.isViewerView() + ? html` + + + ` + : ``} + ${!this.hide && this.view?.is('timeline') + ? html` + ` + : ``} + ${ + // Note: Subtle difference in condition below vs the other views in order + // to always render the live view for live.preload mode. + + // Note: uses nonOverriddenConfig rather than the + // overriden config as it does it's own overriding as part of the camera + // carousel. + this._shouldLivePreload() || (!this.hide && this.view?.is('live')) + ? html` + + + ` + : `` + } + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(viewsStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-views': FrigateCardViews; + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index daaed937..fe054066 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -460,9 +460,12 @@ "timeline": "See media in timeline" }, "timeline": { - "lock": "Lock timeline to a single event", - "select_date": "Choose date", - "unlock": "Unlock timeline" + "pan_behavior": { + "pan": "Pan", + "seek": "Pan seeks across all media", + "seek-in-media": "Pan seeks within selected media item only" + }, + "select_date": "Choose date" }, "elements": { "ptz": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index f2acbc77..6dfc507a 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -450,9 +450,12 @@ "timeline": "Vedi evento nella timeline" }, "timeline": { - "lock": "Blocca la sequenza temporale su un singolo evento", - "select_date": "Scegli la data", - "unlock": "Sblocca la cronologia" + "pan_behavior": { + "pan": "", + "seek": "", + "seek-in-media": "" + }, + "select_date": "Scegli la data" }, "elements": { "ptz": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 53758d44..043603a6 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -471,8 +471,11 @@ "timeline": "Ver evento na linha do tempo" }, "timeline": { - "lock": "Bloquear linha do tempo em um Ășnico evento", - "select_date": "Escolha a data", - "unlock": "Desbloquear linha do tempo" + "pan_behavior": { + "pan": "", + "seek": "", + "seek-in-media": "" + }, + "select_date": "Escolha a data" } } diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index d805963d..9ce81467 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -51,15 +51,15 @@ customElements.whenDefined('ha-camera-stream').then(() => { return this._player?.play(); } - public pause(): void { + public async pause(): Promise { this._player?.pause(); } - public mute(): void { + public async mute(): Promise { this._player?.mute(); } - public unmute(): void { + public async unmute(): Promise { this._player?.unmute(); } @@ -67,10 +67,7 @@ customElements.whenDefined('ha-camera-stream').then(() => { return this._player?.isMuted() ?? true; } - /** - * Seek the video (unsupported). - */ - public seek(seconds: number): void { + public async seek(seconds: number): Promise { this._player?.seek(seconds); } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index b797da40..38f8b148 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -37,11 +37,11 @@ customElements.whenDefined('ha-hls-player').then(() => { return this._video?.play(); } - public pause(): void { + public async pause(): Promise { this._video?.pause(); } - public mute(): void { + public async mute(): Promise { // The muted property is only for the initial muted state. Must explicitly // set the muted on the video player to make the change dynamic. if (this._video) { @@ -49,7 +49,7 @@ customElements.whenDefined('ha-hls-player').then(() => { } } - public unmute(): void { + public async unmute(): Promise { // See note in mute(). if (this._video) { this._video.muted = false; @@ -60,7 +60,7 @@ customElements.whenDefined('ha-hls-player').then(() => { return this._video?.muted ?? true; } - public seek(seconds: number): void { + public async seek(seconds: number): Promise { if (this._video) { hideMediaControlsTemporarily(this._video); this._video.currentTime = seconds; diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 42730d88..e568b6e5 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -9,16 +9,17 @@ // available as compilation time. // ==================================================================== -import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; +import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; +import liveHAComponentsStyle from '../scss/live-ha-components.scss'; +import { FrigateCardMediaPlayer } from '../types.js'; import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; import { hideMediaControlsTemporarily, - MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS } from '../utils/media.js'; -import liveHAComponentsStyle from '../scss/live-ha-components.scss'; customElements.whenDefined('ha-web-rtc-player').then(() => { @customElement('frigate-card-ha-web-rtc-player') @@ -36,11 +37,11 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { return this._video?.play(); } - public pause(): void { + public async pause(): Promise { this._video?.pause(); } - public mute(): void { + public async mute(): Promise { // The muted property is only for the initial muted state. Must explicitly // set the muted on the video player to make the change dynamic. if (this._video) { @@ -48,7 +49,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { } } - public unmute(): void { + public async unmute(): Promise { // See note in mute(). if (this._video) { this._video.muted = false; @@ -59,7 +60,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { return this._video?.muted ?? true; } - public seek(seconds: number): void { + public async seek(seconds: number): Promise { if (this._video) { this._video.currentTime = seconds; } diff --git a/src/scss/card.scss b/src/scss/card.scss index 66736eed..b9833fa9 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -37,8 +37,7 @@ div.main { width: 100%; height: 100%; margin: auto; - display: flex; - justify-content: center; + display: block; // Necessary to get Safari to show border-radius correctly. transform: translateZ(0); @@ -118,11 +117,6 @@ ha-card.triggered { animation: warning-pulse 5s infinite; } -frigate-card-live.hidden { - // Live view will be rendered but hidden for live preloading. - display: none; -} - /************ * Fullscreen *************/ @@ -188,4 +182,4 @@ web-dialog::part(dialog) { // Fixes to render the dialog correctly in Safari. border-radius: 0px; background: transparent; -} \ No newline at end of file +} diff --git a/src/scss/live.scss b/src/scss/live.scss index 624620b1..9f92a4e0 100644 --- a/src/scss/live.scss +++ b/src/scss/live.scss @@ -1,12 +1,5 @@ :host { width: 100%; height: 100%; - display: flex; - flex-direction: column; - gap: 5px; -} - -frigate-card-live-carousel { - flex: 1; - min-height: 0; + display: block; } diff --git a/src/scss/surround-basic.scss b/src/scss/surround-basic.scss index d6ff998a..b73ac06d 100644 --- a/src/scss/surround-basic.scss +++ b/src/scss/surround-basic.scss @@ -14,8 +14,9 @@ overflow: hidden; } -::slotted:not([name]) { +::slotted(:not([slot])) { // Expand the main body to fill available content not otherwise used by the - // surround. + // named slots around the surround. flex: 1; + min-height: 0px; } diff --git a/src/scss/surround.scss b/src/scss/surround.scss index f0f64c07..9f92a4e0 100644 --- a/src/scss/surround.scss +++ b/src/scss/surround.scss @@ -2,4 +2,4 @@ width: 100%; height: 100%; display: block; -} \ No newline at end of file +} diff --git a/src/scss/views.scss b/src/scss/views.scss new file mode 100644 index 00000000..028f0ff1 --- /dev/null +++ b/src/scss/views.scss @@ -0,0 +1,14 @@ +:host { + width: 100%; + height: 100%; + display: block; +} + +:host([hidden]), +.hidden { + // Views content is hidden in these cases: + // - Live view being preloaded but live view not active. + // - Overall views component marked as hidden (e.g. error message to display + // at the card level). + display: none; +} diff --git a/src/types.ts b/src/types.ts index 3dfeafe6..e6cc5324 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1440,11 +1440,11 @@ export interface StateParameters { export interface FrigateCardMediaPlayer { play(): Promise; - pause(): void; - mute(): void; - unmute(): void; + pause(): Promise; + mute(): Promise; + unmute(): Promise; isMuted(): boolean; - seek(seconds: number): void; + seek(seconds: number): Promise; } export interface CardHelpers { diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index e642bece..de7db2f8 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -44,12 +44,12 @@ export function createMediaLoadedInfo( * @param source An event or HTMLElement that should be used as a source. */ export function dispatchMediaLoadedEvent( - element: HTMLElement, + target: HTMLElement, source: Event | HTMLElement, ): void { const mediaLoadedInfo = createMediaLoadedInfo(source); if (mediaLoadedInfo) { - dispatchExistingMediaLoadedInfoAsEvent(element, mediaLoadedInfo); + dispatchExistingMediaLoadedInfoAsEvent(target, mediaLoadedInfo); } } @@ -59,10 +59,10 @@ export function dispatchMediaLoadedEvent( * @param MediaLoadedInfo The MediaLoadedInfo object to send. */ export function dispatchExistingMediaLoadedInfoAsEvent( - element: HTMLElement, + target: EventTarget, MediaLoadedInfo: MediaLoadedInfo, ): void { - dispatchFrigateCardEvent(element, 'media:loaded', MediaLoadedInfo); + dispatchFrigateCardEvent(target, 'media:loaded', MediaLoadedInfo); } /** diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 274875e2..76f3830a 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -194,7 +194,7 @@ export const executeMediaQueryForView = async ( /** * Find the closest matching media object. - * @param mediaArray The media. Must be sorted most recent first. + * @param mediaArray The media. * @param targetTime The target time used to find the relevant child. * @param refPoint Whether to find based on the start or end of the * event/recording. If not specified, the first match is returned rather than diff --git a/src/utils/media.ts b/src/utils/media.ts index 541baa0d..9306695f 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -31,7 +31,6 @@ export const hideMediaControlsTemporarily = ( }; /** - * * @param player The Frigate Card Media Player object. * @param video An underlying video or media player upon which to call play. */ @@ -43,10 +42,14 @@ export const playMediaMutingIfNecessary = async ( // and then try again. This works around some browsers that prevent // auto-play unless the video is muted. if (video?.play) { - video.play().catch((ev) => { + video.play().catch(async (ev) => { if (ev.name === 'NotAllowedError' && !player.isMuted()) { - player.mute(); - video.play().catch(); + await player.mute(); + try { + await video.play(); + } catch (_) { + // Pass. + } } }); } diff --git a/src/view/media.ts b/src/view/media.ts index 5af18c04..758ed34b 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -51,7 +51,7 @@ export class ViewMedia { } public includesTime(seek: Date): boolean { const startTime = this.getStartTime(); - const endTime = this.getEndTime(); + const endTime = this.getEndTime() ?? startTime; return !!startTime && !!endTime && seek >= startTime && seek <= endTime; }