Merge pull request #1070 from dermotduffy/click-always-plays

Allow smooth panning between past & present
This commit is contained in:
Dermot Duffy
2023-04-09 19:36:52 -07:00
committed by GitHub
29 changed files with 548 additions and 380 deletions
+3
View File
@@ -134,6 +134,9 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
// progress. // progress.
return !this.getEndTime(); return !this.getEndTime();
} }
public getVideoContentType(): VideoContentType | null {
return VideoContentType.HLS;
}
public getContentID(): string | null { public getContentID(): string | null {
return this._contentID; return this._contentID;
} }
+43 -139
View File
@@ -5,63 +5,67 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { StyleInfo, styleMap } from 'lit/directives/style-map.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 throttle from 'lodash-es/throttle';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { ViewContext } from 'view';
import 'web-dialog';
import { z } from 'zod'; import { z } from 'zod';
import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js'; import { actionHandler } from './action-handler-directive.js';
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
import { CameraManager } from './camera-manager/manager.js';
import { import {
CardConditionManager, CardConditionManager,
ConditionState, ConditionState,
conditionStateRequestHandler, conditionStateRequestHandler,
getOverriddenConfig, getOverriddenConfig
getOverridesByKey,
} from './card-condition.js'; } from './card-condition.js';
import './components/elements.js'; import './components/elements.js';
import { FrigateCardElements } from './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 './components/menu.js';
import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js'; import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js';
import './components/message.js'; import './components/message.js';
import { renderMessage, renderProgressIndicator } from './components/message.js'; import { renderMessage, renderProgressIndicator } from './components/message.js';
import './components/thumbnail-carousel.js'; import './components/thumbnail-carousel.js';
import './components/views.js';
import { FrigateCardViews } from './components/views.js';
import { isConfigUpgradeable } from './config-mgmt.js'; import { isConfigUpgradeable } from './config-mgmt.js';
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js'; import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
import { getLanguage, loadLanguages, localize } from './localize/localize.js'; import { getLanguage, loadLanguages, localize } from './localize/localize.js';
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { import {
Actions, Actions,
ActionType, ActionType,
CameraConfig, CameraConfig,
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant, FrigateCardConfig,
FRIGATE_CARD_VIEW_DEFAULT,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
frigateCardConfigSchema, frigateCardConfigSchema,
FrigateCardCustomAction, FrigateCardCustomAction,
FrigateCardError, FrigateCardError,
FrigateCardView, FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, FRIGATE_CARD_VIEW_DEFAULT, MediaLoadedInfo,
MediaLoadedInfo, MenuButton, Message, MESSAGE_TYPE_PRIORITIES, RawFrigateCardConfig
MenuButton,
MESSAGE_TYPE_PRIORITIES,
Message,
RawFrigateCardConfig,
} from './types.js'; } from './types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction, createFrigateCardCustomAction,
frigateCardHandleAction, frigateCardHandleAction,
frigateCardHasAction, frigateCardHasAction,
getActionConfigGivenAction, getActionConfigGivenAction
} from './utils/action.js'; } from './utils/action.js';
import { errorToConsole } from './utils/basic.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 { import {
getEntityIcon, getEntityIcon,
getEntityTitle, getEntityTitle,
@@ -69,30 +73,18 @@ import {
isCardInPanel, isCardInPanel,
isHassDifferent, isHassDifferent,
isTriggeredState, isTriggeredState,
sideLoadHomeAssistantElements, sideLoadHomeAssistantElements
} from './utils/ha'; } from './utils/ha';
import { DeviceList, getAllDevices } from './utils/ha/device-registry.js'; 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 { ResolvedMediaCache } from './utils/ha/resolved-media.js';
import { supportsFeature } from './utils/ha/update.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 { FrigateCardInitializer } from './utils/initializer.js';
import 'web-dialog'; import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { downloadMedia } from './utils/download.js';
import { getActionsFromQueryString } from './utils/querystring.js'; import { getActionsFromQueryString } from './utils/querystring.js';
import { View } from './view/view.js';
/** A note on media callbacks: /** A note on media callbacks:
* *
@@ -185,8 +177,7 @@ class FrigateCard extends LitElement {
protected _refMenu: Ref<FrigateCardMenu> = createRef(); protected _refMenu: Ref<FrigateCardMenu> = createRef();
protected _refMain: Ref<HTMLElement> = createRef(); protected _refMain: Ref<HTMLElement> = createRef();
protected _refElements: Ref<FrigateCardElements> = createRef(); protected _refElements: Ref<FrigateCardElements> = createRef();
protected _refImage: Ref<FrigateCardImage> = createRef(); protected _refViews: Ref<FrigateCardViews> = createRef();
protected _refLive: Ref<FrigateCardLive> = createRef();
// user interaction timer ("screensaver" functionality, return to default // user interaction timer ("screensaver" functionality, return to default
// view after user interaction). // view after user interaction).
@@ -246,8 +237,8 @@ class FrigateCard extends LitElement {
if (this._refElements.value) { if (this._refElements.value) {
this._refElements.value.hass = this._hass; this._refElements.value.hass = this._hass;
} }
if (this._refImage.value) { if (this._refViews.value) {
this._refImage.value.hass = this._hass; 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. // to them to avoid the performance hit of a entire card re-render (esp.
// when using card-mod). // when using card-mod).
// https://github.com/dermotduffy/frigate-hass-card/issues/678 // https://github.com/dermotduffy/frigate-hass-card/issues/678
if (this._refLive.value) { if (this._refViews.value) {
this._refLive.value.conditionState = this._conditionState; this._refViews.value.conditionState = this._conditionState;
} }
if (this._refElements.value) { if (this._refElements.value) {
this._refElements.value.conditionState = this._conditionState; this._refElements.value.conditionState = this._conditionState;
@@ -947,18 +938,6 @@ class FrigateCard extends LitElement {
this._initializeBackground(); 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')) { if (changedProps.has('_view')) {
this._setPropertiesForExpandedMode(); this._setPropertiesForExpandedMode();
} }
@@ -2013,7 +1992,18 @@ class FrigateCard extends LitElement {
? renderProgressIndicator({ cardWideConfig: this._cardWideConfig }) ? renderProgressIndicator({ cardWideConfig: this._cardWideConfig })
: // Always want to call render even if there's a message, to : // Always want to call render even if there's a message, to
// ensure live preload is always present (even if not displayed). // ensure live preload is always present (even if not displayed).
this._render()} html`<frigate-card-views
${ref(this._refViews)}
.hass=${this._hass}
.view=${this._view}
.cardWideConfig=${this._cardWideConfig}
.cameraManager=${this._cameraManager}
.resolvedMediaCache=${this._resolvedMediaCache}
.config=${this._getConfig()}
.nonOverriddenConfig=${this._config}
.conditionState=${this._conditionState}
.hide=${!!this._message}
></frigate-card-views>`}
${ ${
// Keep message rendering to last to show messages that may have been // Keep message rendering to last to show messages that may have been
// generated during the render. // generated during the render.
@@ -2044,92 +2034,6 @@ class FrigateCard extends LitElement {
</ha-card>`); </ha-card>`);
} }
/**
* 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` <frigate-card-image
${ref(this._refImage)}
.imageConfig=${this._getConfig().image}
.view=${this._view}
.hass=${this._hass}
.cameraConfig=${cameraConfig}
>
</frigate-card-image>`
: ``}
${!this._message && this._view.isGalleryView()
? html` <frigate-card-gallery
.hass=${this._hass}
.view=${this._view}
.galleryConfig=${this._getConfig().media_gallery}
.cameraManager=${this._cameraManager}
.cardWideConfig=${this._cardWideConfig}
>
</frigate-card-gallery>`
: ``}
${!this._message && this._view.isViewerView()
? html` <frigate-card-viewer
.hass=${this._hass}
.view=${this._view}
.viewerConfig=${this._getConfig().media_viewer}
.resolvedMediaCache=${this._resolvedMediaCache}
.cameraManager=${this._cameraManager}
.cardWideConfig=${this._cardWideConfig}
>
</frigate-card-viewer>`
: ``}
${!this._message && this._view.is('timeline')
? html` <frigate-card-timeline
.hass=${this._hass}
.view=${this._view}
.timelineConfig=${this._getConfig().timeline}
.cameraManager=${this._cameraManager}
.cardWideConfig=${this._cardWideConfig}
>
</frigate-card-timeline>`
: ``}
${
// Note: Subtle difference in condition below vs the other views in order
// to always render the live view for live.preload mode.
// Note: <frigate-card-live> 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`
<frigate-card-live
${ref(this._refLive)}
.hass=${this._hass}
.view=${this._view}
.liveConfig=${this._config.live}
.conditionState=${this._conditionState}
.liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')}
.cameraManager=${this._cameraManager}
.cardWideConfig=${this._cardWideConfig}
class="${classMap(liveClasses)}"
>
</frigate-card-live>
`
: ``
}
`;
}
protected firstUpdated(): void { protected firstUpdated(): void {
// Execute query string actions after first render is complete. // Execute query string actions after first render is complete.
getActionsFromQueryString().forEach((action) => this._cardActionHandler(action)); getActionsFromQueryString().forEach((action) => this._cardActionHandler(action));
+4 -4
View File
@@ -75,17 +75,17 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
return this._player?.video?.play(); return this._player?.video?.play();
} }
public pause(): void { public async pause(): Promise<void> {
this._player?.video?.pause(); this._player?.video?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
if (this._player?.video) { if (this._player?.video) {
this._player.video.muted = true; this._player.video.muted = true;
} }
} }
public unmute(): void { public async unmute(): Promise<void> {
if (this._player?.video) { if (this._player?.video) {
this._player.video.muted = false; this._player.video.muted = false;
} }
@@ -95,7 +95,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
return this._player?.video.muted ?? true; return this._player?.video.muted ?? true;
} }
public seek(seconds: number): void { public async seek(seconds: number): Promise<void> {
if (this._player?.video) { if (this._player?.video) {
this._player.video.currentTime = seconds; this._player.video.currentTime = seconds;
} }
+4 -4
View File
@@ -23,15 +23,15 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
return this._playerRef.value?.play(); return this._playerRef.value?.play();
} }
public pause(): void { public async pause(): Promise<void> {
this._playerRef.value?.pause(); this._playerRef.value?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
this._playerRef.value?.mute(); this._playerRef.value?.mute();
} }
public unmute(): void { public async unmute(): Promise<void> {
this._playerRef.value?.unmute(); this._playerRef.value?.unmute();
} }
@@ -39,7 +39,7 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
return this._playerRef.value?.isMuted() ?? true; return this._playerRef.value?.isMuted() ?? true;
} }
public seek(seconds: number): void { public async seek(seconds: number): Promise<void> {
this._playerRef.value?.seek(seconds); this._playerRef.value?.seek(seconds);
} }
+5 -5
View File
@@ -21,15 +21,15 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
this._playing = true; this._playing = true;
} }
public pause(): void { public async pause(): Promise<void> {
this._playing = false; this._playing = false;
} }
public mute(): void { public async mute(): Promise<void> {
// Not implemented. // Not implemented.
} }
public unmute(): void { public async unmute(): Promise<void> {
// Not implemented. // Not implemented.
} }
@@ -38,7 +38,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public seek(_seconds: number): void { public async seek(_seconds: number): Promise<void> {
// Not implemented. // Not implemented.
} }
@@ -51,7 +51,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
return html` <frigate-card-image return html` <frigate-card-image
.imageConfig=${{ .imageConfig=${{
mode: this.cameraConfig.image.url ? 'url' as const : 'camera' as const, mode: this.cameraConfig.image.url ? ('url' as const) : ('camera' as const),
refresh_seconds: this._playing ? this.cameraConfig.image.refresh_seconds : 0, refresh_seconds: this._playing ? this.cameraConfig.image.refresh_seconds : 0,
url: this.cameraConfig.image.url, url: this.cameraConfig.image.url,
// Don't need to pass layout options as FrigateCardLiveProvider has // Don't need to pass layout options as FrigateCardLiveProvider has
+4 -4
View File
@@ -43,18 +43,18 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
return this._jsmpegVideoPlayer?.play(); return this._jsmpegVideoPlayer?.play();
} }
public pause(): void { public async pause(): Promise<void> {
this._jsmpegVideoPlayer?.stop(); this._jsmpegVideoPlayer?.stop();
} }
public mute(): void { public async mute(): Promise<void> {
const player = this._jsmpegVideoPlayer?.player; const player = this._jsmpegVideoPlayer?.player;
if (player) { if (player) {
player.volume = 0; player.volume = 0;
} }
} }
public unmute(): void { public async unmute(): Promise<void> {
const player = this._jsmpegVideoPlayer?.player; const player = this._jsmpegVideoPlayer?.player;
if (player) { if (player) {
player.volume = 1; player.volume = 1;
@@ -66,7 +66,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public seek(_seconds: number): void { public async seek(_seconds: number): Promise<void> {
// JSMPEG does not support seeking. // JSMPEG does not support seeking.
} }
+5 -5
View File
@@ -44,18 +44,18 @@ export class FrigateCardLiveWebRTCCard
return this._getPlayer()?.play(); return this._getPlayer()?.play();
} }
public pause(): void { public async pause(): Promise<void> {
this._getPlayer()?.pause(); this._getPlayer()?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
const player = this._getPlayer(); const player = this._getPlayer();
if (player) { if (player) {
player.muted = true; player.muted = true;
} }
} }
public unmute(): void { public async unmute(): Promise<void> {
const player = this._getPlayer(); const player = this._getPlayer();
if (player) { if (player) {
player.muted = false; player.muted = false;
@@ -66,7 +66,7 @@ export class FrigateCardLiveWebRTCCard
return this._getPlayer()?.muted ?? true; return this._getPlayer()?.muted ?? true;
} }
public seek(seconds: number): void { public async seek(seconds: number): Promise<void> {
const player = this._getPlayer(); const player = this._getPlayer();
if (player) { if (player) {
player.currentTime = seconds; player.currentTime = seconds;
@@ -78,7 +78,7 @@ export class FrigateCardLiveWebRTCCard
// Reset the player when reconnected to the DOM. // Reset the player when reconnected to the DOM.
// https://github.com/dermotduffy/frigate-hass-card/issues/996 // https://github.com/dermotduffy/frigate-hass-card/issues/996
this.requestUpdate(); this.requestUpdate();
} }
/** /**
+100 -67
View File
@@ -69,6 +69,11 @@ declare module 'view' {
} }
} }
interface LastMediaLoadedInfo {
mediaLoadedInfo: MediaLoadedInfo;
source: EventTarget;
}
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
/** /**
@@ -141,9 +146,11 @@ export class FrigateCardLive extends LitElement {
// foreground and background (in preload mode). // foreground and background (in preload mode).
protected _intersectionObserver: IntersectionObserver; protected _intersectionObserver: IntersectionObserver;
// MediaLoadedInfo object and message from the underlying live object. In the // MediaLoadedInfo object and target from the underlying live object. In the
// case of pre-loading these may be propagated upwards later. // case of pre-loading these may be propagated later (from the original
protected _backgroundMediaLoadedInfo: MediaLoadedInfo | null = null; // source).
protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null;
protected _messageReceivedPostRender = false; protected _messageReceivedPostRender = false;
protected _renderKey = 0; protected _renderKey = 0;
@@ -164,12 +171,17 @@ export class FrigateCardLive extends LitElement {
if ( if (
!this._inBackground && !this._inBackground &&
!this._messageReceivedPostRender && !this._messageReceivedPostRender &&
this._backgroundMediaLoadedInfo this._lastMediaLoadedInfo
) { ) {
// If this isn't being rendered in the background, the last render did not // 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. // generate a message and there's a saved MediaInfo, dispatch it upwards.
dispatchExistingMediaLoadedInfoAsEvent(this, this._backgroundMediaLoadedInfo); dispatchExistingMediaLoadedInfoAsEvent(
this._backgroundMediaLoadedInfo = null; // 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 // Trigger a re-render which may be necessary if the prior render resulted
@@ -217,18 +229,10 @@ export class FrigateCardLive extends LitElement {
return; return;
} }
const config = getOverriddenConfig(
this.liveConfig,
this.liveOverrides,
this.conditionState,
) as LiveConfig;
// Notes: // Notes:
// - See use of liveConfig and not config below -- the carousel will // - See use of liveConfig and not config below -- the carousel will
// independently override the liveConfig to reflect the camera in the // independently override the liveConfig to reflect the camera in the
// carousel (not necessarily the selected camera). // 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 // - Various events are captured to prevent them propagating upwards if the
// card is in the background. // card is in the background.
// - The entire returned template is keyed to allow for the whole template // - 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). // is received when the card is in the background).
const result = html`${keyed( const result = html`${keyed(
this._renderKey, this._renderKey,
html`<frigate-card-surround html`
.hass=${this.hass}
.view=${this.view}
.fetchMedia=${config.controls.thumbnails.media}
.thumbnailConfig=${config.controls.thumbnails}
.timelineConfig=${config.controls.timeline}
.cameraManager=${this.cameraManager}
.inBackground=${this._inBackground}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:message=${(ev: CustomEvent<Message>) => {
this._renderKey++;
this._messageReceivedPostRender = true;
if (this._inBackground) {
ev.stopPropagation();
}
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (this._inBackground) {
this._backgroundMediaLoadedInfo = ev.detail;
ev.stopPropagation();
}
}}
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
if (this._inBackground) {
ev.stopPropagation();
}
}}
>
<frigate-card-live-carousel <frigate-card-live-carousel
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
@@ -273,9 +250,30 @@ export class FrigateCardLive extends LitElement {
.liveOverrides=${this.liveOverrides} .liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
@frigate-card:message=${(ev: CustomEvent<Message>) => {
this._renderKey++;
this._messageReceivedPostRender = true;
if (this._inBackground) {
ev.stopPropagation();
}
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._lastMediaLoadedInfo = {
source: ev.composedPath()[0],
mediaLoadedInfo: ev.detail,
};
if (this._inBackground) {
ev.stopPropagation();
}
}}
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
if (this._inBackground) {
ev.stopPropagation();
}
}}
> >
</frigate-card-live-carousel> </frigate-card-live-carousel>
</frigate-card-surround>`, `,
)}`; )}`;
this._messageReceivedPostRender = false; this._messageReceivedPostRender = false;
@@ -327,18 +325,22 @@ export class FrigateCardLiveCarousel extends LitElement {
updated(changedProperties: PropertyValues): void { updated(changedProperties: PropertyValues): void {
super.updated(changedProperties); super.updated(changedProperties);
const frigateCardMediaCarousel = this._refMediaCarousel.value; if (changedProperties.has('inBackground')) {
this.updateComplete.then(async () => {
if (frigateCardMediaCarousel && changedProperties.has('inBackground')) { const frigateCardMediaCarousel = this._refMediaCarousel.value;
// If this has changed to be in the background (i.e. preloaded but not if (frigateCardMediaCarousel) {
// visible) take the appropriate play/pause/mute/unmute actions. await frigateCardMediaCarousel.updateComplete;
if (this.inBackground) { // If this has changed to be in the background (i.e. preloaded but not
frigateCardMediaCarousel.autoPause(); // visible) take the appropriate play/pause/mute/unmute actions.
frigateCardMediaCarousel.autoMute(); if (this.inBackground) {
} else { frigateCardMediaCarousel.autoPause();
frigateCardMediaCarousel.autoPlay(); frigateCardMediaCarousel.autoMute();
frigateCardMediaCarousel.autoUnmute(); } else {
} frigateCardMediaCarousel.autoPlay();
frigateCardMediaCarousel.autoUnmute();
}
}
});
} }
} }
@@ -722,21 +724,41 @@ export class FrigateCardLiveProvider
@state() @state()
protected _isVideoMediaLoaded = false; protected _isVideoMediaLoaded = false;
protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef(); protected _refProvider: Ref<LitElement & FrigateCardMediaPlayer> = 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 <frigate-card-live> element first renders in the
// background. These calls fail without waiting for loading here.
protected _importPromises: Promise<unknown>[] = [];
public async play(): Promise<void> { public async play(): Promise<void> {
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<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.pause(); this._refProvider.value?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.mute(); this._refProvider.value?.mute();
} }
public unmute(): void { public async unmute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.unmute(); this._refProvider.value?.unmute();
} }
@@ -744,7 +766,9 @@ export class FrigateCardLiveProvider
return this._refProvider.value?.isMuted() ?? true; return this._refProvider.value?.isMuted() ?? true;
} }
public seek(seconds: number): void { public async seek(seconds: number): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.seek(seconds); this._refProvider.value?.seek(seconds);
} }
@@ -813,25 +837,34 @@ export class FrigateCardLiveProvider
if (changedProps.has('liveConfig')) { if (changedProps.has('liveConfig')) {
updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout); updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout);
if (this.liveConfig?.show_image_during_load) { if (this.liveConfig?.show_image_during_load) {
import('./live-image.js'); this._importPromises.push(import('./live-image.js'));
} }
} }
if (changedProps.has('cameraConfig')) { if (changedProps.has('cameraConfig')) {
const provider = this._getResolvedProvider(); const provider = this._getResolvedProvider();
if (provider === 'jsmpeg') { if (provider === 'jsmpeg') {
import('./live-jsmpeg.js'); this._importPromises.push(import('./live-jsmpeg.js'));
} else if (provider === 'ha') { } else if (provider === 'ha') {
import('./live-ha.js'); this._importPromises.push(import('./live-ha.js'));
} else if (provider === 'webrtc-card') { } else if (provider === 'webrtc-card') {
import('./live-webrtc-card.js'); this._importPromises.push(import('./live-webrtc-card.js'));
} else if (provider === 'image') { } else if (provider === 'image') {
import('./live-image.js'); this._importPromises.push(import('./live-image.js'));
} else if (provider === 'go2rtc') { } else if (provider === 'go2rtc') {
import('./live-go2rtc.js'); this._importPromises.push(import('./live-go2rtc.js'));
} }
} }
} }
override async getUpdateComplete(): Promise<boolean> {
// 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. * Master render method.
* @returns A rendered template. * @returns A rendered template.
+6 -15
View File
@@ -49,9 +49,6 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: MiniTimelineControlConfig; public timelineConfig?: MiniTimelineControlConfig;
@property({ attribute: false })
public inBackground?: boolean;
// If fetchMedia is not specified, no fetching is done. // If fetchMedia is not specified, no fetching is done.
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public fetchMedia?: ClipsOrSnapshotsOrAll; public fetchMedia?: ClipsOrSnapshotsOrAll;
@@ -75,7 +72,6 @@ export class FrigateCardSurround extends LitElement {
!this.cameraManager || !this.cameraManager ||
!this.cardWideConfig || !this.cardWideConfig ||
!this.fetchMedia || !this.fetchMedia ||
this.inBackground ||
!this.hass || !this.hass ||
!this.view || !this.view ||
this.view.query || 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 // do so if properties relevant to the request have changed (as per their
// hasChanged). // hasChanged).
if ( if (
['view', 'fetch', 'browseMediaParams', 'inBackground'].some((prop) => ['view', 'fetch', 'browseMediaParams'].some((prop) => changedProperties.has(prop))
changedProperties.has(prop),
)
) { ) {
this._fetchMedia(); this._fetchMedia();
} }
@@ -162,7 +156,7 @@ export class FrigateCardSurround extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.thumbnailConfig) { if (!this.hass || !this.view) {
return; return;
} }
@@ -183,9 +177,7 @@ export class FrigateCardSurround extends LitElement {
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')} @frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
> >
${this.thumbnailConfig && ${this.thumbnailConfig && this.thumbnailConfig.mode !== 'none'
this.thumbnailConfig.mode !== 'none' &&
!this.inBackground
? html` <frigate-card-thumbnail-carousel ? html` <frigate-card-thumbnail-carousel
slot=${this.thumbnailConfig.mode} slot=${this.thumbnailConfig.mode}
.hass=${this.hass} .hass=${this.hass}
@@ -215,15 +207,14 @@ export class FrigateCardSurround extends LitElement {
> >
</frigate-card-thumbnail-carousel>` </frigate-card-thumbnail-carousel>`
: ''} : ''}
${this.timelineConfig?.mode && ${this.timelineConfig && this.timelineConfig.mode !== 'none'
this.timelineConfig.mode !== 'none' &&
!this.inBackground
? html` <frigate-card-timeline-core ? html` <frigate-card-timeline-core
slot=${this.timelineConfig.mode} slot=${this.timelineConfig.mode}
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.itemClickAction=${this.view.isViewerView() || .itemClickAction=${this.view.isViewerView() ||
this.thumbnailConfig.mode === 'none' !this.thumbnailConfig ||
this.thumbnailConfig?.mode === 'none'
? 'play' ? 'play'
: 'select'} : 'select'}
.cameraIDs=${this._cameraIDsForTimeline} .cameraIDs=${this._cameraIDsForTimeline}
+69 -51
View File
@@ -76,9 +76,11 @@ interface TimelineRangeChange extends TimelineWindow {
interface TimelineViewContext { interface TimelineViewContext {
window?: TimelineWindow; window?: TimelineWindow;
panBehavior?: TimelinePanBehavior;
} }
type TimelineItemClickAction = 'play' | 'select'; type TimelineItemClickAction = 'play' | 'select';
type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media';
declare module 'view' { declare module 'view' {
interface ViewContext { interface ViewContext {
@@ -197,7 +199,7 @@ export class FrigateCardTimelineCore extends LitElement {
public itemClickAction?: TimelineItemClickAction; public itemClickAction?: TimelineItemClickAction;
@state() @state()
protected _locked = false; protected _panBehavior: TimelinePanBehavior = 'seek';
protected _targetBarVisible = false; protected _targetBarVisible = false;
@@ -272,9 +274,18 @@ export class FrigateCardTimelineCore extends LitElement {
} }
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
const lockTitle = this._locked const panTitle =
? localize('timeline.unlock') this._panBehavior === 'pan'
: localize('timeline.lock'); ? localize('timeline.pan_behavior.pan')
: this._panBehavior === 'seek'
? localize('timeline.pan_behavior.seek')
: localize('timeline.pan_behavior.seek-in-media');
const panIcon =
this._panBehavior === 'pan'
? 'mdi:pan-horizontal'
: this._panBehavior === 'seek'
? 'mdi:filmstrip'
: 'mdi:lock';
return html` ${capabilities?.supportsTimeline return html` ${capabilities?.supportsTimeline
? html` <div ? html` <div
@@ -287,12 +298,17 @@ export class FrigateCardTimelineCore extends LitElement {
<div class="timeline-tools"> <div class="timeline-tools">
${this._shouldSupportSeeking() ${this._shouldSupportSeeking()
? html` <ha-icon ? html` <ha-icon
.icon=${`mdi:${this._locked ? 'lock' : 'lock-open-variant'}`} .icon=${panIcon}
@click=${() => { @click=${() => {
this._locked = !this._locked; this._panBehavior =
this._panBehavior === 'pan'
? 'seek'
: this._panBehavior === 'seek'
? 'seek-in-media'
: 'pan';
}} }}
aria-label="${lockTitle}" aria-label="${panTitle}"
title="${lockTitle}" title="${panTitle}"
> >
</ha-icon>` </ha-icon>`
: ''} : ''}
@@ -340,9 +356,10 @@ export class FrigateCardTimelineCore extends LitElement {
} }
if ( if (
this._shouldSupportSeeking() &&
this._timeline && this._timeline &&
properties.byUser && 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.type !== 'wheel' &&
properties.event.additionalEvent !== 'pinchin' && properties.event.additionalEvent !== 'pinchin' &&
properties.event.additionalEvent !== 'pinchout' properties.event.additionalEvent !== 'pinchout'
@@ -365,13 +382,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
protected _shouldSupportSeeking(): boolean { protected _shouldSupportSeeking(): boolean {
const cameraIDs = this._getTimelineCameraIDs(); return this.mini;
if (!this._timeline || !cameraIDs) {
return false;
}
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
return (this.view?.isViewerView() && capabilities?.canSeek) ?? false;
} }
/** /**
@@ -385,8 +396,8 @@ export class FrigateCardTimelineCore extends LitElement {
const targetBarOn = const targetBarOn =
this._shouldSupportSeeking() && this._shouldSupportSeeking() &&
(!this._locked || (this._panBehavior === 'seek' ||
(this.mini && (this._panBehavior === 'seek-in-media' &&
this._timeline.getSelection().some((id) => { this._timeline.getSelection().some((id) => {
const item = this._timelineSource?.dataset?.get(id); const item = this._timelineSource?.dataset?.get(id);
return ( return (
@@ -439,6 +450,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this.view || !this.view ||
!this.hass || !this.hass ||
!this.cameraManager || !this.cameraManager ||
this._panBehavior === 'pan' ||
// Skip range changes that do not have hammerjs pan directions associated // Skip range changes that do not have hammerjs pan directions associated
// with them, as these outliers cause media matching issues below. // with them, as these outliers cause media matching issues below.
!properties.event.additionalEvent !properties.event.additionalEvent
@@ -446,37 +458,38 @@ export class FrigateCardTimelineCore extends LitElement {
return; return;
} }
const canSeek = !!this.view?.isViewerView(); const canSeek = this._shouldSupportSeeking();
const newResults = this._locked const newResults =
? null this._panBehavior === 'seek-in-media'
: results ? null
.clone() : results
.resetSelectedResult() .clone()
.selectBestResult((media) => .resetSelectedResult()
findClosestMediaIndex( .selectBestResult((media) =>
media, findClosestMediaIndex(
targetTime, media,
properties.event.additionalEvent === 'panright' ? 'end' : 'start', targetTime,
), properties.event.additionalEvent === 'panright' ? 'end' : 'start',
); ),
);
if ( const desiredView: FrigateCardView = this.mini
canSeek || ? targetTime >= new Date()
(newResults && ? 'live'
newResults.hasSelectedResult() && : 'media'
newResults.getResult() !== results.getResult()) : this.view.view;
) {
this.view this.view
.evolve({ .evolve({
...(newResults && view: desiredView,
newResults.hasSelectedResult() && { queryResults: newResults }), ...(newResults &&
}) // Whether or not to set the timeline window. newResults.hasSelectedResult() && { queryResults: newResults }),
.mergeInContext({ }) // Whether or not to set the timeline window.
...(canSeek && { mediaViewer: { seek: targetTime } }), .mergeInContext({
...this._setWindowInContext(properties), ...(canSeek && { mediaViewer: { seek: targetTime } }),
}) ...this._getTimelineContext(properties),
.dispatchChangeEvent(this); })
} .dispatchChangeEvent(this);
} }
/** /**
@@ -667,7 +680,7 @@ export class FrigateCardTimelineCore extends LitElement {
// 'flicker' for the user as the viewer reloads all the media). // 'flicker' for the user as the viewer reloads all the media).
const newResults = newView?.queryResults; const newResults = newView?.queryResults;
if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { 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; : null;
const context = this.view.context?.timeline; const context = this.view.context?.timeline;
if (context && context.panBehavior) {
this._panBehavior = context.panBehavior;
}
if (context && context.window) { if (context && context.window) {
desiredWindow = context.window; desiredWindow = context.window;
} else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { } else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
@@ -1021,7 +1038,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery) !this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
) { ) {
(await this._createViewWithEventMediaQuery(freshMediaQuery)) (await this._createViewWithEventMediaQuery(freshMediaQuery))
?.mergeInContext(this._setWindowInContext(desiredWindow)) ?.mergeInContext(this._getTimelineContext(desiredWindow))
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
} }
@@ -1046,11 +1063,12 @@ export class FrigateCardTimelineCore extends LitElement {
* Generate the context for timeline views. * Generate the context for timeline views.
* @returns The TimelineViewContext object. * @returns The TimelineViewContext object.
*/ */
protected _setWindowInContext(window?: TimelineWindow): ViewContext { protected _getTimelineContext(window?: TimelineWindow): ViewContext {
const newWindow = window ?? this._timeline?.getWindow(); const newWindow = window ?? this._timeline?.getWindow();
return { return {
timeline: { timeline: {
...this.view?.context?.timeline, ...this.view?.context?.timeline,
panBehavior: this._panBehavior,
...(newWindow && { window: newWindow }), ...(newWindow && { window: newWindow }),
}, },
}; };
+2 -12
View File
@@ -7,11 +7,6 @@ import { View } from '../view/view';
import './surround.js'; import './surround.js';
import './timeline-core.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') @customElement('frigate-card-timeline')
export class FrigateCardTimeline extends LitElement { export class FrigateCardTimeline extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -38,12 +33,7 @@ export class FrigateCardTimeline extends LitElement {
return html``; return html``;
} }
return html` <frigate-card-surround return html`
.hass=${this.hass}
.view=${this.view}
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
.cameraManager=${this.cameraManager}
>
<frigate-card-timeline-core <frigate-card-timeline-core
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
@@ -56,7 +46,7 @@ export class FrigateCardTimeline extends LitElement {
: 'select'} : 'select'}
> >
</frigate-card-timeline-core> </frigate-card-timeline-core>
</frigate-card-surround>`; `;
} }
/** /**
+7 -14
View File
@@ -142,14 +142,7 @@ export class FrigateCardViewer extends LitElement {
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
} }
return html` <frigate-card-surround return html`
.hass=${this.hass}
.view=${this.view}
.thumbnailConfig=${this.viewerConfig.controls.thumbnails}
.timelineConfig=${this.viewerConfig.controls.timeline}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
<frigate-card-viewer-carousel <frigate-card-viewer-carousel
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
@@ -159,7 +152,7 @@ export class FrigateCardViewer extends LitElement {
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
> >
</frigate-card-viewer-carousel> </frigate-card-viewer-carousel>
</frigate-card-surround>`; `;
} }
/** /**
@@ -565,17 +558,17 @@ export class FrigateCardViewerProvider
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef(); protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
public async play(): Promise<void> { public async play(): Promise<void> {
playMediaMutingIfNecessary( await playMediaMutingIfNecessary(
this, this,
this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value, this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value,
); );
} }
public pause(): void { public async pause(): Promise<void> {
(this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause(); (this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
if (this._refFrigateCardMediaPlayer.value) { if (this._refFrigateCardMediaPlayer.value) {
this._refFrigateCardMediaPlayer.value?.mute(); this._refFrigateCardMediaPlayer.value?.mute();
} else if (this._refVideoProvider.value) { } else if (this._refVideoProvider.value) {
@@ -583,7 +576,7 @@ export class FrigateCardViewerProvider
} }
} }
public unmute(): void { public async unmute(): Promise<void> {
if (this._refFrigateCardMediaPlayer.value) { if (this._refFrigateCardMediaPlayer.value) {
this._refFrigateCardMediaPlayer.value?.mute(); this._refFrigateCardMediaPlayer.value?.mute();
} else if (this._refVideoProvider.value) { } else if (this._refVideoProvider.value) {
@@ -600,7 +593,7 @@ export class FrigateCardViewerProvider
return true; return true;
} }
public seek(seconds: number): void { public async seek(seconds: number): Promise<void> {
if (this._refFrigateCardMediaPlayer.value) { if (this._refFrigateCardMediaPlayer.value) {
return this._refFrigateCardMediaPlayer.value.seek(seconds); return this._refFrigateCardMediaPlayer.value.seek(seconds);
} else if (this._refVideoProvider.value) { } else if (this._refVideoProvider.value) {
+224
View File
@@ -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<View>;
@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` <frigate-card-surround
class="${classMap(overallClasses)}"
.hass=${this.hass}
.view=${this.view}
.fetchMedia=${this.view?.is('live')
? this.config.live.controls.thumbnails.media
: undefined}
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
${!this.hide && this.view?.is('image') && cameraConfig
? html` <frigate-card-image
.imageConfig=${this.config.image}
.view=${this.view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
>
</frigate-card-image>`
: ``}
${!this.hide && this.view?.isGalleryView()
? html` <frigate-card-gallery
.hass=${this.hass}
.view=${this.view}
.galleryConfig=${this.config.media_gallery}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-gallery>`
: ``}
${!this.hide && this.view?.isViewerView()
? html`
<frigate-card-viewer
.hass=${this.hass}
.view=${this.view}
.viewerConfig=${this.config.media_viewer}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-viewer>
`
: ``}
${!this.hide && this.view?.is('timeline')
? html` <frigate-card-timeline
.hass=${this.hass}
.view=${this.view}
.timelineConfig=${this.config.timeline}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-timeline>`
: ``}
${
// Note: Subtle difference in condition below vs the other views in order
// to always render the live view for live.preload mode.
// Note: <frigate-card-live> 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`
<frigate-card-live
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live}
.conditionState=${this.conditionState}
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
class="${classMap(liveClasses)}"
>
</frigate-card-live>
`
: ``
}
</frigate-card-surround>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(viewsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-views': FrigateCardViews;
}
}
+6 -3
View File
@@ -460,9 +460,12 @@
"timeline": "See media in timeline" "timeline": "See media in timeline"
}, },
"timeline": { "timeline": {
"lock": "Lock timeline to a single event", "pan_behavior": {
"select_date": "Choose date", "pan": "Pan",
"unlock": "Unlock timeline" "seek": "Pan seeks across all media",
"seek-in-media": "Pan seeks within selected media item only"
},
"select_date": "Choose date"
}, },
"elements": { "elements": {
"ptz": { "ptz": {
+6 -3
View File
@@ -450,9 +450,12 @@
"timeline": "Vedi evento nella timeline" "timeline": "Vedi evento nella timeline"
}, },
"timeline": { "timeline": {
"lock": "Blocca la sequenza temporale su un singolo evento", "pan_behavior": {
"select_date": "Scegli la data", "pan": "",
"unlock": "Sblocca la cronologia" "seek": "",
"seek-in-media": ""
},
"select_date": "Scegli la data"
}, },
"elements": { "elements": {
"ptz": { "ptz": {
+6 -3
View File
@@ -471,8 +471,11 @@
"timeline": "Ver evento na linha do tempo" "timeline": "Ver evento na linha do tempo"
}, },
"timeline": { "timeline": {
"lock": "Bloquear linha do tempo em um único evento", "pan_behavior": {
"select_date": "Escolha a data", "pan": "",
"unlock": "Desbloquear linha do tempo" "seek": "",
"seek-in-media": ""
},
"select_date": "Escolha a data"
} }
} }
+4 -7
View File
@@ -51,15 +51,15 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return this._player?.play(); return this._player?.play();
} }
public pause(): void { public async pause(): Promise<void> {
this._player?.pause(); this._player?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
this._player?.mute(); this._player?.mute();
} }
public unmute(): void { public async unmute(): Promise<void> {
this._player?.unmute(); this._player?.unmute();
} }
@@ -67,10 +67,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return this._player?.isMuted() ?? true; return this._player?.isMuted() ?? true;
} }
/** public async seek(seconds: number): Promise<void> {
* Seek the video (unsupported).
*/
public seek(seconds: number): void {
this._player?.seek(seconds); this._player?.seek(seconds);
} }
+4 -4
View File
@@ -37,11 +37,11 @@ customElements.whenDefined('ha-hls-player').then(() => {
return this._video?.play(); return this._video?.play();
} }
public pause(): void { public async pause(): Promise<void> {
this._video?.pause(); this._video?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
// The muted property is only for the initial muted state. Must explicitly // The muted property is only for the initial muted state. Must explicitly
// set the muted on the video player to make the change dynamic. // set the muted on the video player to make the change dynamic.
if (this._video) { if (this._video) {
@@ -49,7 +49,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
} }
} }
public unmute(): void { public async unmute(): Promise<void> {
// See note in mute(). // See note in mute().
if (this._video) { if (this._video) {
this._video.muted = false; this._video.muted = false;
@@ -60,7 +60,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
return this._video?.muted ?? true; return this._video?.muted ?? true;
} }
public seek(seconds: number): void { public async seek(seconds: number): Promise<void> {
if (this._video) { if (this._video) {
hideMediaControlsTemporarily(this._video); hideMediaControlsTemporarily(this._video);
this._video.currentTime = seconds; this._video.currentTime = seconds;
+8 -7
View File
@@ -9,16 +9,17 @@
// available as compilation time. // 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 { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js'; import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.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 { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS
} from '../utils/media.js'; } from '../utils/media.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
customElements.whenDefined('ha-web-rtc-player').then(() => { customElements.whenDefined('ha-web-rtc-player').then(() => {
@customElement('frigate-card-ha-web-rtc-player') @customElement('frigate-card-ha-web-rtc-player')
@@ -36,11 +37,11 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
return this._video?.play(); return this._video?.play();
} }
public pause(): void { public async pause(): Promise<void> {
this._video?.pause(); this._video?.pause();
} }
public mute(): void { public async mute(): Promise<void> {
// The muted property is only for the initial muted state. Must explicitly // The muted property is only for the initial muted state. Must explicitly
// set the muted on the video player to make the change dynamic. // set the muted on the video player to make the change dynamic.
if (this._video) { if (this._video) {
@@ -48,7 +49,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
} }
} }
public unmute(): void { public async unmute(): Promise<void> {
// See note in mute(). // See note in mute().
if (this._video) { if (this._video) {
this._video.muted = false; this._video.muted = false;
@@ -59,7 +60,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
return this._video?.muted ?? true; return this._video?.muted ?? true;
} }
public seek(seconds: number): void { public async seek(seconds: number): Promise<void> {
if (this._video) { if (this._video) {
this._video.currentTime = seconds; this._video.currentTime = seconds;
} }
+2 -8
View File
@@ -37,8 +37,7 @@ div.main {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: auto; margin: auto;
display: flex; display: block;
justify-content: center;
// Necessary to get Safari to show border-radius correctly. // Necessary to get Safari to show border-radius correctly.
transform: translateZ(0); transform: translateZ(0);
@@ -118,11 +117,6 @@ ha-card.triggered {
animation: warning-pulse 5s infinite; animation: warning-pulse 5s infinite;
} }
frigate-card-live.hidden {
// Live view will be rendered but hidden for live preloading.
display: none;
}
/************ /************
* Fullscreen * Fullscreen
*************/ *************/
@@ -188,4 +182,4 @@ web-dialog::part(dialog) {
// Fixes to render the dialog correctly in Safari. // Fixes to render the dialog correctly in Safari.
border-radius: 0px; border-radius: 0px;
background: transparent; background: transparent;
} }
+1 -8
View File
@@ -1,12 +1,5 @@
:host { :host {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: block;
flex-direction: column;
gap: 5px;
}
frigate-card-live-carousel {
flex: 1;
min-height: 0;
} }
+3 -2
View File
@@ -14,8 +14,9 @@
overflow: hidden; overflow: hidden;
} }
::slotted:not([name]) { ::slotted(:not([slot])) {
// Expand the main body to fill available content not otherwise used by the // Expand the main body to fill available content not otherwise used by the
// surround. // named slots around the surround.
flex: 1; flex: 1;
min-height: 0px;
} }
+1 -1
View File
@@ -2,4 +2,4 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
} }
+14
View File
@@ -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;
}
+4 -4
View File
@@ -1440,11 +1440,11 @@ export interface StateParameters {
export interface FrigateCardMediaPlayer { export interface FrigateCardMediaPlayer {
play(): Promise<void>; play(): Promise<void>;
pause(): void; pause(): Promise<void>;
mute(): void; mute(): Promise<void>;
unmute(): void; unmute(): Promise<void>;
isMuted(): boolean; isMuted(): boolean;
seek(seconds: number): void; seek(seconds: number): Promise<void>;
} }
export interface CardHelpers { export interface CardHelpers {
+4 -4
View File
@@ -44,12 +44,12 @@ export function createMediaLoadedInfo(
* @param source An event or HTMLElement that should be used as a source. * @param source An event or HTMLElement that should be used as a source.
*/ */
export function dispatchMediaLoadedEvent( export function dispatchMediaLoadedEvent(
element: HTMLElement, target: HTMLElement,
source: Event | HTMLElement, source: Event | HTMLElement,
): void { ): void {
const mediaLoadedInfo = createMediaLoadedInfo(source); const mediaLoadedInfo = createMediaLoadedInfo(source);
if (mediaLoadedInfo) { if (mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(element, mediaLoadedInfo); dispatchExistingMediaLoadedInfoAsEvent(target, mediaLoadedInfo);
} }
} }
@@ -59,10 +59,10 @@ export function dispatchMediaLoadedEvent(
* @param MediaLoadedInfo The MediaLoadedInfo object to send. * @param MediaLoadedInfo The MediaLoadedInfo object to send.
*/ */
export function dispatchExistingMediaLoadedInfoAsEvent( export function dispatchExistingMediaLoadedInfoAsEvent(
element: HTMLElement, target: EventTarget,
MediaLoadedInfo: MediaLoadedInfo, MediaLoadedInfo: MediaLoadedInfo,
): void { ): void {
dispatchFrigateCardEvent<MediaLoadedInfo>(element, 'media:loaded', MediaLoadedInfo); dispatchFrigateCardEvent<MediaLoadedInfo>(target, 'media:loaded', MediaLoadedInfo);
} }
/** /**
+1 -1
View File
@@ -194,7 +194,7 @@ export const executeMediaQueryForView = async (
/** /**
* Find the closest matching media object. * 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 targetTime The target time used to find the relevant child.
* @param refPoint Whether to find based on the start or end of the * @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 * event/recording. If not specified, the first match is returned rather than
+7 -4
View File
@@ -31,7 +31,6 @@ export const hideMediaControlsTemporarily = (
}; };
/** /**
*
* @param player The Frigate Card Media Player object. * @param player The Frigate Card Media Player object.
* @param video An underlying video or media player upon which to call play. * @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 // and then try again. This works around some browsers that prevent
// auto-play unless the video is muted. // auto-play unless the video is muted.
if (video?.play) { if (video?.play) {
video.play().catch((ev) => { video.play().catch(async (ev) => {
if (ev.name === 'NotAllowedError' && !player.isMuted()) { if (ev.name === 'NotAllowedError' && !player.isMuted()) {
player.mute(); await player.mute();
video.play().catch(); try {
await video.play();
} catch (_) {
// Pass.
}
} }
}); });
} }
+1 -1
View File
@@ -51,7 +51,7 @@ export class ViewMedia {
} }
public includesTime(seek: Date): boolean { public includesTime(seek: Date): boolean {
const startTime = this.getStartTime(); const startTime = this.getStartTime();
const endTime = this.getEndTime(); const endTime = this.getEndTime() ?? startTime;
return !!startTime && !!endTime && seek >= startTime && seek <= endTime; return !!startTime && !!endTime && seek >= startTime && seek <= endTime;
} }