Set timeline to now not to most recent event.

This commit is contained in:
Dermot Duffy
2024-04-07 20:21:09 -07:00
parent 14a4f89726
commit f67620397f
22 changed files with 828 additions and 336 deletions
+224
View File
@@ -0,0 +1,224 @@
import sub from 'date-fns/sub';
import { LitElement, ReactiveController } from 'lit';
import { ViewContext } from 'view';
import { CameraManager } from '../../camera-manager/manager.js';
import { FrigateCardMessageEventTarget } from '../../components/message.js';
import { CardWideConfig, LiveConfig } from '../../config/types.js';
import { MediaLoadedInfo, Message } from '../../types.js';
import {
FrigateCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../../utils/media-info.js';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../../utils/media-to-view.js';
import { FrigateCardViewChangeEventTarget, View } from '../../view/view.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
ptzVisible?: boolean;
fetchThumbnails?: boolean;
}
declare module 'view' {
interface ViewContext {
live?: LiveViewContext;
}
}
interface LastMediaLoadedInfo {
mediaLoadedInfo: MediaLoadedInfo;
source: EventTarget;
}
type LiveControllerHost = LitElement &
FrigateCardMediaLoadedEventTarget &
FrigateCardMessageEventTarget &
FrigateCardViewChangeEventTarget;
export class LiveController implements ReactiveController {
protected _host: LiveControllerHost;
// Whether or not the live view is currently in the background (i.e. preloaded
// but not visible).
protected _inBackground = false;
// Intersection handler is used to detect when the live view flips between
// foreground and background (in preload mode).
protected _intersectionObserver: IntersectionObserver;
// Whether or not to allow updates.
protected _messageReceived = false;
// MediaLoadedInfo object and target from the underlying live media. In the
// case of pre-loading these may be propagated later (from the original
// source).
protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null;
protected _renderEpoch = 0;
constructor(host: LiveControllerHost) {
this._host = host;
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
public shouldUpdate(): boolean {
// Don't process updates if it's in the background and a message was
// received (otherwise an error message thrown by the background live
// component may continually be re-spammed hitting performance).
return !this._inBackground || !this._messageReceived;
}
public hostConnected(): void {
this._intersectionObserver.observe(this._host);
this._host.addEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
this._host.addEventListener('frigate-card:message', this._handleMessage);
this._host.addEventListener('frigate-card:view:change', this._handleViewChange);
}
public hostDisconnected(): void {
this._intersectionObserver.disconnect();
this._host.removeEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
this._host.removeEventListener('frigate-card:message', this._handleMessage);
this._host.removeEventListener('frigate-card:view:change', this._handleViewChange);
}
public clearMessageReceived(): void {
this._messageReceived = false;
}
public isInBackground(): boolean {
return this._inBackground;
}
public getRenderEpoch(): number {
return this._renderEpoch;
}
protected _handleMessage = (ev: CustomEvent<Message>): void => {
this._messageReceived = true;
if (this._inBackground) {
ev.stopPropagation();
// Force the whole DOM to re-render next time.
this._renderEpoch++;
}
};
protected _handleMediaLoaded = (ev: CustomEvent<MediaLoadedInfo>): void => {
this._lastMediaLoadedInfo = {
source: ev.composedPath()[0],
mediaLoadedInfo: ev.detail,
};
if (this._inBackground) {
ev.stopPropagation();
}
};
protected _handleViewChange = (ev: CustomEvent<View>): void => {
if (this._inBackground) {
ev.stopPropagation();
}
};
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
const wasInBackground = this._inBackground;
this._inBackground = !entries.some((entry) => entry.isIntersecting);
if (!this._inBackground && !this._messageReceived && 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(
// 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,
);
}
if (wasInBackground !== this._inBackground) {
this._host.requestUpdate();
}
}
/**
* Fetch thumbnail media when a target is not already specified in the view
* (e.g. first time live is visited).
*/
public async fetchMediaInBackgroundIfNecessary(
view: View,
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
overriddenLiveConfig: LiveConfig,
): Promise<void> {
if (
this._inBackground ||
// Only fetch media if there isn't any already.
view.query ||
overriddenLiveConfig.controls.thumbnails.mode === 'none' ||
view.context?.live?.fetchThumbnails === false
) {
return;
}
const mediaType = overriddenLiveConfig.controls.thumbnails.media_type;
const now = new Date();
const viewContext: ViewContext = {
// Force the window to start at the most recent time, not
// necessarily when the most recent event/recording was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
timeline: {
window: {
start: sub(now, {
seconds: overriddenLiveConfig.controls.timeline.window_seconds,
}),
end: now,
},
},
};
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (mediaType === 'events') {
await changeViewToRecentEventsForCameraAndDependents(
this._host,
cameraManager,
cardWideConfig,
view,
{
allCameras: view.isGrid(),
targetView: view.view,
eventsMediaType: overriddenLiveConfig.controls.thumbnails.events_media_type,
select: 'latest',
// Force the window to start at the most recent time, not
// necessarily when the most recent event was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
viewContext: viewContext,
},
);
} else if (mediaType === 'recordings') {
await changeViewToRecentRecordingForCameraAndDependents(
this._host,
cameraManager,
cardWideConfig,
view,
{
allCameras: view.isGrid(),
targetView: view.view,
select: 'latest',
viewContext: viewContext,
},
);
}
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import {
setOrRemoveAttribute, setOrRemoveAttribute,
} from '../utils/basic'; } from '../utils/basic';
import { import {
FrigateMediaLoadedEventTarget, FrigateCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent, dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent, dispatchMediaUnloadedEvent,
} from '../utils/media-info'; } from '../utils/media-info';
@@ -23,7 +23,7 @@ const MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH = 600;
const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0; const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0;
type GridID = string; type GridID = string;
type MediaGridChild = HTMLElement & FrigateMediaLoadedEventTarget; type MediaGridChild = HTMLElement & FrigateCardMediaLoadedEventTarget;
type MediaGridContents = Map<GridID, MediaGridChild>; type MediaGridContents = Map<GridID, MediaGridChild>;
export interface MediaGridSelected { export interface MediaGridSelected {
+28 -121
View File
@@ -19,6 +19,7 @@ import {
getOverriddenConfig, getOverriddenConfig,
} from '../../card-controller/conditions-manager.js'; } from '../../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
import { LiveController } from '../../components-lib/live/live-controller.js';
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
import { import {
CameraConfig, CameraConfig,
@@ -34,12 +35,7 @@ import basicBlockStyle from '../../scss/basic-block.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveGridStyle from '../../scss/live-grid.scss'; import liveGridStyle from '../../scss/live-grid.scss';
import liveProviderStyle from '../../scss/live-provider.scss'; import liveProviderStyle from '../../scss/live-provider.scss';
import { import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
MediaLoadedInfo,
Message,
} from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { aspectRatioToString, contentsChanged } from '../../utils/basic.js'; import { aspectRatioToString, contentsChanged } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
@@ -48,10 +44,7 @@ import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/a
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js'; import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js'; import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js'; import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
import { import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js';
import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
@@ -67,24 +60,6 @@ import {
getDefaultTitleConfigForView, getDefaultTitleConfigForView,
} from '../title-control.js'; } from '../title-control.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
ptzVisible?: boolean;
}
declare module 'view' {
interface ViewContext {
live?: LiveViewContext;
}
}
interface LastMediaLoadedInfo {
mediaLoadedInfo: MediaLoadedInfo;
source: EventTarget;
}
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
@customElement('frigate-card-live') @customElement('frigate-card-live')
@@ -101,7 +76,7 @@ export class FrigateCardLive extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig; public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false }) @property({ attribute: false }>)
public overriddenLiveConfig?: LiveConfig; public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
@@ -119,76 +94,32 @@ export class FrigateCardLive extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public triggeredCameraIDs?: Set<string>; public triggeredCameraIDs?: Set<string>;
// Whether or not the live view is currently in the background (i.e. preloaded protected _controller = new LiveController(this);
// but not visible)
@state()
protected _inBackground?: boolean = false;
// Intersection handler is used to detect when the live view flips between
// foreground and background (in preload mode).
protected _intersectionObserver: IntersectionObserver;
// 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;
constructor() {
super();
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
/**
* Called when the live view intersects with the viewport.
* @param entries The IntersectionObserverEntry entries (should be only 1).
*/
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
this._inBackground = !entries.some((entry) => entry.isIntersecting);
if (
!this._inBackground &&
!this._messageReceivedPostRender &&
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(
// 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
// in a message.
if (this._messageReceivedPostRender && !this._inBackground) {
this.requestUpdate();
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_changedProps: PropertyValues): boolean { protected shouldUpdate(_changedProps: PropertyValues): boolean {
// Don't process updates if it's in the background and a message was return this._controller.shouldUpdate();
// received (otherwise an error message thrown by the background live
// component may continually be re-spammed hitting performance).
return !this._inBackground || !this._messageReceivedPostRender;
} }
connectedCallback(): void { protected willUpdate(changedProperties: PropertyValues): void {
this._intersectionObserver.observe(this); if (
super.connectedCallback(); ['view', 'cameraManager', 'cardWideConfig', 'overriddenLiveConfig'].some((prop) =>
changedProperties.has(prop),
) &&
this.view &&
this.cameraManager &&
this.cardWideConfig &&
this.overriddenLiveConfig
) {
this._controller.fetchMediaInBackgroundIfNecessary(
this.view,
this.cameraManager,
this.cardWideConfig,
this.overriddenLiveConfig,
);
} }
disconnectedCallback(): void { this._controller.clearMessageReceived();
super.disconnectedCallback();
this._intersectionObserver.disconnect();
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
@@ -210,49 +141,25 @@ export class FrigateCardLive extends LitElement {
// - The entire returned template is keyed to allow for the whole template // - The entire returned template is keyed to allow for the whole template
// to be re-rendered in certain circumstances (specifically: if a message // to be re-rendered in certain circumstances (specifically: if a message
// is received when the card is in the background). // is received when the card is in the background).
const result = html`${keyed( return html`${keyed(
this._renderKey, this._controller.getRenderEpoch(),
html` html`
<frigate-card-live-grid <frigate-card-live-grid
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig} .nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig} .overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._inBackground} .inBackground=${this._controller.isInBackground()}
.conditionsManagerEpoch=${this.conditionsManagerEpoch} .conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${this.liveOverrides} .liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.microphoneManager=${this.microphoneManager} .microphoneManager=${this.microphoneManager}
.triggeredCameraIDs=${this.triggeredCameraIDs} .triggeredCameraIDs=${this.triggeredCameraIDs}
@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-grid> </frigate-card-live-grid>
`, `,
)}`; )}`;
this._messageReceivedPostRender = false;
return result;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@@ -525,7 +432,7 @@ export class FrigateCardLiveCarousel extends LitElement {
}) })
// Don't yet fetch thumbnails (they will be fetched when the carousel // Don't yet fetch thumbnails (they will be fetched when the carousel
// settles). // settles).
.mergeInContext({ thumbnails: { fetch: false } }) .mergeInContext({ live: { fetchThumbnails: false } })
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
} }
@@ -673,7 +580,7 @@ export class FrigateCardLiveCarousel extends LitElement {
@frigate-card:carousel:select=${this._setViewHandler.bind(this)} @frigate-card:carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:carousel:settle=${() => { @frigate-card:carousel:settle=${() => {
// Fetch the thumbnails after the carousel has settled. // Fetch the thumbnails after the carousel has settled.
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } }); dispatchViewContextChangeEvent(this, { live: { fetchThumbnails: true } });
}} }}
@frigate-card:media:loaded=${() => { @frigate-card:media:loaded=${() => {
if (this._refTitleControl.value) { if (this._refTitleControl.value) {
+30
View File
@@ -200,6 +200,36 @@ export function dispatchFrigateCardErrorEvent(
} }
} }
// Facilitates correct typing of event handlers.
export interface FrigateCardMessageEventTarget extends EventTarget {
addEventListener(
event: 'frigate-card:message',
listener: (
this: FrigateCardMessageEventTarget,
ev: CustomEvent<Message>,
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'frigate-card:message',
listener: (
this: FrigateCardMessageEventTarget,
ev: CustomEvent<Message>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'frigate-card-progress-indicator': FrigateCardProgressIndicator; 'frigate-card-progress-indicator': FrigateCardProgressIndicator;
+1 -89
View File
@@ -14,31 +14,12 @@ import {
ThumbnailsControlConfig, ThumbnailsControlConfig,
} from '../config/types.js'; } from '../config/types.js';
import basicBlockStyle from '../scss/basic-block.scss'; import basicBlockStyle from '../scss/basic-block.scss';
import { import { ExtendedHomeAssistant } from '../types.js';
ClipsOrSnapshotsOrAll,
EventsOrRecordings,
ExtendedHomeAssistant,
} from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
import './surround-basic.js'; import './surround-basic.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
interface ThumbnailViewContext {
// Whether or not to fetch thumbnails.
fetch?: boolean;
}
declare module 'view' {
interface ViewContext {
thumbnails?: ThumbnailViewContext;
}
}
@customElement('frigate-card-surround') @customElement('frigate-card-surround')
export class FrigateCardSurround extends LitElement { export class FrigateCardSurround extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -53,13 +34,6 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: MiniTimelineControlConfig; public timelineConfig?: MiniTimelineControlConfig;
// If fetchMedia is not specified, no fetching is done.
@property({ attribute: false, hasChanged: contentsChanged })
public fetchMediaType?: EventsOrRecordings;
@property({ attribute: false, hasChanged: contentsChanged })
public fetchEventsMediaType?: ClipsOrSnapshotsOrAll;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -68,56 +42,6 @@ export class FrigateCardSurround extends LitElement {
protected _cameraIDsForTimeline?: Set<string>; protected _cameraIDsForTimeline?: Set<string>;
/**
* Fetch thumbnail media when a target is not specified in the view (e.g. for
* the live view).
* @param param Task parameters.
* @returns
*/
protected async _fetchMedia(): Promise<void> {
if (
!this.cameraManager ||
!this.cardWideConfig ||
!this.fetchMediaType ||
!this.fetchEventsMediaType ||
!this.hass ||
!this.view ||
this.view.query ||
!this.thumbnailConfig ||
this.thumbnailConfig.mode === 'none' ||
!(this.view.context?.thumbnails?.fetch ?? true)
) {
return;
}
if (this.fetchMediaType === 'events') {
await changeViewToRecentEventsForCameraAndDependents(
this,
this.cameraManager,
this.cardWideConfig,
this.view,
{
allCameras: this.view.isGrid(),
targetView: this.view.view,
eventsMediaType: this.fetchEventsMediaType,
select: 'latest',
},
);
} else if (this.fetchMediaType === 'recordings') {
await changeViewToRecentRecordingForCameraAndDependents(
this,
this.cameraManager,
this.cardWideConfig,
this.view,
{
allCameras: this.view.isGrid(),
targetView: this.view.view,
select: 'latest',
},
);
}
}
/** /**
* Determine if a drawer is being used. * Determine if a drawer is being used.
* @returns `true` if a drawer is used, `false` otherwise. * @returns `true` if a drawer is used, `false` otherwise.
@@ -128,9 +52,6 @@ export class FrigateCardSurround extends LitElement {
); );
} }
/**
* Called before each update.
*/
protected willUpdate(changedProperties: PropertyValues): void { protected willUpdate(changedProperties: PropertyValues): void {
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') { if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
import('./timeline-core.js'); import('./timeline-core.js');
@@ -147,15 +68,6 @@ export class FrigateCardSurround extends LitElement {
) { ) {
this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined;
} }
// Once the component will certainly update, dispatch a media request. Only
// do so if properties relevant to the request have changed (as per their
// hasChanged).
if (
['view', 'fetch', 'browseMediaParams'].some((prop) => changedProperties.has(prop))
) {
this._fetchMedia();
}
} }
protected _getCameraIDsForTimeline(): Set<string> | null { protected _getCameraIDsForTimeline(): Set<string> | null {
+8 -1
View File
@@ -651,7 +651,14 @@ export class FrigateCardTimelineCore extends LitElement {
}): Promise<void> { }): Promise<void> {
this._removeTargetBar(); this._removeTargetBar();
if (!this._timeline || !this.view) { if (
!this._timeline ||
!this.view ||
// When in mini mode, something else is in charge of the primary media
// population (e.g. the live view), in this case only act when the user
// themselves are interacting with the timeline.
(this.mini && !properties.byUser)
) {
return; return;
} }
+4 -6
View File
@@ -163,12 +163,6 @@ export class FrigateCardViews extends LitElement {
class="${classMap(overallClasses)}" class="${classMap(overallClasses)}"
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.fetchMediaType=${this.view?.is('live')
? this.overriddenConfig.live.controls.thumbnails.media_type
: undefined}
.fetchEventsMediaType=${this.view?.is('live')
? this.overriddenConfig.live.controls.thumbnails.events_media_type
: undefined}
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined} .thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined} .timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
@@ -254,6 +248,10 @@ export class FrigateCardViews extends LitElement {
: `` : ``
} }
</frigate-card-surround>`; </frigate-card-surround>`;
// .fetchMediaType=${this.view?.is('live') ? this.overriddenConfig.live.controls.thumbnails.media_type : undefined}
// .fetchEventsMediaType=${this.view?.is('live') ? this.overriddenConfig.live.controls.thumbnails.events_media_type : undefined}
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
-1
View File
@@ -8,7 +8,6 @@ import { z } from 'zod';
export type ClipsOrSnapshots = 'clips' | 'snapshots'; export type ClipsOrSnapshots = 'clips' | 'snapshots';
export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all'; export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all';
export type EventsOrRecordings = 'events' | 'recordings';
export class FrigateCardError extends Error { export class FrigateCardError extends Error {
context?: unknown; context?: unknown;
@@ -1,11 +1,11 @@
import { EmblaCarouselType } from 'embla-carousel'; import { EmblaCarouselType } from 'embla-carousel';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { MediaLoadedInfo } from '../../../../types'; import { MediaLoadedInfo } from '../../../../types';
import { import {
FrigateCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent, dispatchExistingMediaLoadedInfoAsEvent,
FrigateMediaLoadedEventTarget,
} from '../../../media-info'; } from '../../../media-info';
import { LooseOptionsType } from 'embla-carousel/components/Options';
declare module 'embla-carousel/components/Plugins' { declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType { interface EmblaPluginsType {
@@ -17,7 +17,7 @@ type AutoMediaLoadedInfoType = CreatePluginType<LoosePluginType, LooseOptionsTyp
function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType { function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
let emblaApi: EmblaCarouselType; let emblaApi: EmblaCarouselType;
let slides: (HTMLElement & FrigateMediaLoadedEventTarget)[] = []; let slides: (HTMLElement & FrigateCardMediaLoadedEventTarget)[] = [];
const mediaLoadedInfo: MediaLoadedInfo[] = []; const mediaLoadedInfo: MediaLoadedInfo[] = [];
function init(emblaApiInstance: EmblaCarouselType): void { function init(emblaApiInstance: EmblaCarouselType): void {
+6 -6
View File
@@ -112,19 +112,19 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
); );
} }
// Facilitates correct Typescript typing of media:loaded/unloaded event handlers. // Facilitates correct typing of event handlers.
export interface FrigateMediaLoadedEventTarget extends EventTarget { export interface FrigateCardMediaLoadedEventTarget extends EventTarget {
addEventListener( addEventListener(
event: 'frigate-card:media:loaded', event: 'frigate-card:media:loaded',
listener: ( listener: (
this: FrigateMediaLoadedEventTarget, this: FrigateCardMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>, ev: CustomEvent<MediaLoadedInfo>,
) => void, ) => void,
options?: AddEventListenerOptions | boolean, options?: AddEventListenerOptions | boolean,
): void; ): void;
addEventListener( addEventListener(
event: 'frigate-card:media:unloaded', event: 'frigate-card:media:unloaded',
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, listener: (this: FrigateCardMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: AddEventListenerOptions | boolean, options?: AddEventListenerOptions | boolean,
): void; ): void;
addEventListener( addEventListener(
@@ -135,14 +135,14 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget {
removeEventListener( removeEventListener(
event: 'frigate-card:media:loaded', event: 'frigate-card:media:loaded',
listener: ( listener: (
this: FrigateMediaLoadedEventTarget, this: FrigateCardMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>, ev: CustomEvent<MediaLoadedInfo>,
) => void, ) => void,
options?: boolean | EventListenerOptions, options?: boolean | EventListenerOptions,
): void; ): void;
removeEventListener( removeEventListener(
event: 'frigate-card:media:unloaded', event: 'frigate-card:media:unloaded',
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, listener: (this: FrigateCardMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: boolean | EventListenerOptions, options?: boolean | EventListenerOptions,
): void; ): void;
removeEventListener( removeEventListener(
+9 -1
View File
@@ -28,6 +28,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
targetView?: FrigateCardView; targetView?: FrigateCardView;
select?: ResultSelectType; select?: ResultSelectType;
useCache?: boolean; useCache?: boolean;
viewContext?: ViewContext;
}, },
): Promise<void> => { ): Promise<void> => {
const capabilitySearch: CapabilitySearchOptions = const capabilitySearch: CapabilitySearchOptions =
@@ -61,6 +62,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
targetView: options?.targetView, targetView: options?.targetView,
select: options?.select, select: options?.select,
useCache: options?.useCache, useCache: options?.useCache,
viewContext: options?.viewContext
}, },
) )
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
@@ -103,6 +105,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
targetView?: FrigateCardView; targetView?: FrigateCardView;
select?: ResultSelectType; select?: ResultSelectType;
useCache?: boolean; useCache?: boolean;
viewContext?: ViewContext;
}, },
): Promise<void> => { ): Promise<void> => {
const cameraIDs = options?.allCameras const cameraIDs = options?.allCameras
@@ -131,6 +134,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
targetView: options?.targetView, targetView: options?.targetView,
select: options?.select, select: options?.select,
useCache: options?.useCache, useCache: options?.useCache,
viewContext: options?.viewContext,
}, },
) )
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
@@ -159,6 +163,7 @@ export const executeMediaQueryForView = async (
targetTime?: Date; targetTime?: Date;
select?: ResultSelectType; select?: ResultSelectType;
useCache?: boolean; useCache?: boolean;
viewContext?: ViewContext;
}, },
): Promise<View | null> => { ): Promise<View | null> => {
const queries = query.getQueries(); const queries = query.getQueries();
@@ -195,7 +200,8 @@ export const executeMediaQueryForView = async (
view: options?.targetView, view: options?.targetView,
camera: cameraID, camera: cameraID,
}) })
.mergeInContext(viewerContext); .mergeInContext(options?.viewContext)
.mergeInContext(viewerContext)
}; };
export const executeMediaQueryForViewWithErrorDispatching = async ( export const executeMediaQueryForViewWithErrorDispatching = async (
@@ -209,6 +215,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async (
targetTime?: Date; targetTime?: Date;
select?: ResultSelectType; select?: ResultSelectType;
useCache?: boolean; useCache?: boolean;
viewContext?: ViewContext;
}, },
): Promise<View | null> => { ): Promise<View | null> => {
try { try {
@@ -218,6 +225,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async (
targetTime: options?.targetTime, targetTime: options?.targetTime,
select: options?.select, select: options?.select,
useCache: options?.useCache, useCache: options?.useCache,
viewContext: options?.viewContext,
}); });
} catch (e: unknown) { } catch (e: unknown) {
errorToConsole(e as Error); errorToConsole(e as Error);
+30
View File
@@ -269,6 +269,36 @@ export class View {
} }
} }
// Facilitates correct typing of event handlers.
export interface FrigateCardViewChangeEventTarget extends EventTarget {
addEventListener(
event: 'frigate-card:view:change',
listener: (
this: FrigateCardViewChangeEventTarget,
ev: CustomEvent<View>,
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'frigate-card:view:change',
listener: (
this: FrigateCardViewChangeEventTarget,
ev: CustomEvent<View>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
/** /**
* Dispatch an event to change the view context. * Dispatch an event to change the view context.
* @param target The EventTarget to send the event from. * @param target The EventTarget to send the event from.
@@ -1,16 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import {
CardElementManager,
CardHTMLElement,
} from '../../src/card-controller/card-element-manager';
import { createCardAPI } from '../test-utils';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { CardElementManager } from '../../src/card-controller/card-element-manager';
const createElement = (): CardHTMLElement => { import { createCardAPI, createLitElement } from '../test-utils';
const element = document.createElement('div') as unknown as CardHTMLElement;
element.requestUpdate = vi.fn();
return element as CardHTMLElement;
};
// @vitest-environment jsdom // @vitest-environment jsdom
describe('CardElementManager', () => { describe('CardElementManager', () => {
@@ -20,7 +11,7 @@ describe('CardElementManager', () => {
}); });
it('should get element', () => { it('should get element', () => {
const element = createElement(); const element = createLitElement();
const manager = new CardElementManager( const manager = new CardElementManager(
createCardAPI(), createCardAPI(),
element, element,
@@ -35,7 +26,7 @@ describe('CardElementManager', () => {
const callback = vi.fn(); const callback = vi.fn();
const manager = new CardElementManager( const manager = new CardElementManager(
createCardAPI(), createCardAPI(),
createElement(), createLitElement(),
callback, callback,
() => undefined, () => undefined,
); );
@@ -49,7 +40,7 @@ describe('CardElementManager', () => {
const callback = vi.fn(); const callback = vi.fn();
const manager = new CardElementManager( const manager = new CardElementManager(
createCardAPI(), createCardAPI(),
createElement(), createLitElement(),
() => undefined, () => undefined,
callback, callback,
); );
@@ -60,7 +51,7 @@ describe('CardElementManager', () => {
}); });
it('should update', () => { it('should update', () => {
const element = createElement(); const element = createLitElement();
const manager = new CardElementManager( const manager = new CardElementManager(
createCardAPI(), createCardAPI(),
element, element,
@@ -73,7 +64,7 @@ describe('CardElementManager', () => {
}); });
it('should get hasUpdated', () => { it('should get hasUpdated', () => {
const element = createElement(); const element = createLitElement();
element.hasUpdated = true; element.hasUpdated = true;
const manager = new CardElementManager( const manager = new CardElementManager(
createCardAPI(), createCardAPI(),
@@ -86,7 +77,7 @@ describe('CardElementManager', () => {
}); });
it('should get height', () => { it('should get height', () => {
const element = createElement(); const element = createLitElement();
element.getBoundingClientRect = vi.fn().mockReturnValue({ element.getBoundingClientRect = vi.fn().mockReturnValue({
width: 200, width: 200,
height: 800, height: 800,
@@ -106,7 +97,7 @@ describe('CardElementManager', () => {
const windowAddEventListener = vi.spyOn(global.window, 'addEventListener'); const windowAddEventListener = vi.spyOn(global.window, 'addEventListener');
const addEventListener = vi.fn(); const addEventListener = vi.fn();
const element = createElement(); const element = createLitElement();
element.addEventListener = addEventListener; element.addEventListener = addEventListener;
const api = createCardAPI(); const api = createCardAPI();
@@ -147,7 +138,7 @@ describe('CardElementManager', () => {
it('should disconnect', () => { it('should disconnect', () => {
const windowRemoveEventListener = vi.spyOn(global.window, 'removeEventListener'); const windowRemoveEventListener = vi.spyOn(global.window, 'removeEventListener');
const element = createElement(); const element = createLitElement();
element.setAttribute('panel', ''); element.setAttribute('panel', '');
const removeEventListener = vi.fn(); const removeEventListener = vi.fn();
+1 -1
View File
@@ -78,7 +78,7 @@ describe('ViewManager.setView', () => {
it('should set view with new context', () => { it('should set view with new context', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ViewManager(api); const manager = new ViewManager(api);
const context = { thumbnails: { fetch: false } }; const context = { live: { fetchThumbnails: false } };
// Setting context with no existing view does nothing. // Setting context with no existing view does nothing.
manager.setViewWithNewContext(context); manager.setViewWithNewContext(context);
@@ -0,0 +1,377 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { LiveController } from '../../../src/components-lib/live/live-controller';
import { dispatchMessageEvent } from '../../../src/components/message';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../../../src/utils/media-to-view';
import { EventMediaQueries } from '../../../src/view/media-queries';
import {
IntersectionObserverMock,
callIntersectionHandler,
createCameraManager,
createConfig,
createLitElement,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
createParent,
createView,
createViewChangeEvent,
} from '../../test-utils';
vi.mock('../../../src/utils/media-to-view');
// @vitest-environment jsdom
describe('LiveController', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
});
it('should be constructable', () => {
const controller = new LiveController(createLitElement());
expect(controller).toBeTruthy();
});
it('should connect and disconnect', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('frigate-card:message', eventListener);
const controller = new LiveController(host);
controller.hostConnected();
callIntersectionHandler(false);
dispatchMessageEvent(host, 'message', 'info');
expect(eventListener).toBeCalledTimes(0);
controller.hostDisconnected();
dispatchMessageEvent(host, 'message', 'info');
expect(eventListener).toBeCalledTimes(1);
});
describe('should handle background / foreground', () => {
it('should start in the foreground', () => {
const controller = new LiveController(createLitElement());
expect(controller.isInBackground()).toBeFalsy();
});
it('should handle changing to background', () => {
const element = createLitElement();
const controller = new LiveController(element);
expect(controller.isInBackground()).toBeFalsy();
expect(element.requestUpdate).toBeCalledTimes(0);
callIntersectionHandler(true);
expect(controller.isInBackground()).toBeFalsy();
expect(element.requestUpdate).toBeCalledTimes(0);
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
expect(element.requestUpdate).toBeCalledTimes(1);
});
it('should dispatch media loaded on background change', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('frigate-card:media:loaded', eventListener);
const controller = new LiveController(host);
const mediaLoadedInfo = createMediaLoadedInfo();
controller.hostConnected();
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
expect(eventListener).toBeCalledTimes(0);
callIntersectionHandler(true);
expect(eventListener).toBeCalledTimes(1);
expect(eventListener).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: mediaLoadedInfo,
}),
);
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
expect(eventListener).toBeCalledTimes(2);
callIntersectionHandler(false);
dispatchMessageEvent(host, 'message', 'info');
callIntersectionHandler(true);
expect(eventListener).toBeCalledTimes(2);
controller.clearMessageReceived();
callIntersectionHandler(true);
expect(eventListener).toBeCalledTimes(3);
});
});
describe('should correctly allow updates', () => {
it('when not in background', () => {
const controller = new LiveController(createLitElement());
expect(controller.shouldUpdate()).toBeTruthy();
});
it('when in background without message', () => {
const host = createLitElement();
const controller = new LiveController(host);
controller.hostConnected();
callIntersectionHandler(false);
expect(controller.shouldUpdate()).toBeTruthy();
});
it('when in background with message', () => {
const host = createLitElement();
const controller = new LiveController(host);
controller.hostConnected();
callIntersectionHandler(false);
dispatchMessageEvent(host, 'message', 'info');
expect(controller.shouldUpdate()).toBeFalsy();
});
});
it('should handle message', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('frigate-card:message', eventListener);
const controller = new LiveController(host);
controller.hostConnected();
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
const firstRenderEpoch = controller.getRenderEpoch();
dispatchMessageEvent(host, 'message', 'info');
expect(eventListener).toBeCalledTimes(0);
const secondRenderEpoch = controller.getRenderEpoch();
expect(secondRenderEpoch).not.toBe(firstRenderEpoch);
callIntersectionHandler(true);
dispatchMessageEvent(host, 'message', 'info');
expect(eventListener).toBeCalledTimes(1);
expect(controller.getRenderEpoch()).toBe(secondRenderEpoch);
});
it('should handle view change', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('frigate-card:view:change', eventListener);
const controller = new LiveController(host);
controller.hostConnected();
const view = createView();
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
host.dispatchEvent(createViewChangeEvent(view));
expect(eventListener).toBeCalledTimes(0);
callIntersectionHandler(true);
expect(controller.isInBackground()).toBeFalsy();
host.dispatchEvent(createViewChangeEvent(view));
expect(eventListener).toBeCalledTimes(1);
});
describe('should fetch media', () => {
it('when in background', async () => {
const controller = new LiveController(createLitElement());
callIntersectionHandler(false);
await controller.fetchMediaInBackgroundIfNecessary(
createView(),
createCameraManager(),
{},
createConfig().live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('when has existing query', async () => {
const controller = new LiveController(createLitElement());
await controller.fetchMediaInBackgroundIfNecessary(
createView({ query: new EventMediaQueries() }),
createCameraManager(),
{},
createConfig().live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('when has no thumbnails', async () => {
const controller = new LiveController(createLitElement());
await controller.fetchMediaInBackgroundIfNecessary(
createView(),
createCameraManager(),
{},
createConfig({
live: {
controls: {
thumbnails: {
mode: 'none',
},
},
},
}).live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('when fetch disabled in context', async () => {
const controller = new LiveController(createLitElement());
await controller.fetchMediaInBackgroundIfNecessary(
createView({
context: {
live: {
fetchThumbnails: false,
},
},
}),
createCameraManager(),
{},
createConfig().live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
describe('with fetch', () => {
const now = new Date('2024-04-07T19:43');
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterAll(() => {
vi.useRealTimers();
});
it('events', async () => {
const host = createLitElement();
const controller = new LiveController(host);
const view = createView();
const cameraManager = createCameraManager();
const cardWideConfig = {};
await controller.fetchMediaInBackgroundIfNecessary(
view,
cameraManager,
cardWideConfig,
createConfig({
live: {
controls: {
thumbnails: {
media_type: 'events',
events_media_type: 'all',
},
timeline: {
window_seconds: 3600,
},
},
},
}).live,
);
expect(changeViewToRecentEventsForCameraAndDependents).toBeCalledWith(
host,
cameraManager,
cardWideConfig,
view,
expect.objectContaining({
allCameras: false,
targetView: 'live',
eventsMediaType: 'all',
select: 'latest',
viewContext: expect.objectContaining({
timeline: {
window: {
start: new Date('2024-04-07T18:43'),
end: now,
},
},
}),
}),
);
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('recordings', async () => {
const host = createLitElement();
const controller = new LiveController(host);
const view = createView();
const cameraManager = createCameraManager();
const cardWideConfig = {};
await controller.fetchMediaInBackgroundIfNecessary(
view,
cameraManager,
cardWideConfig,
createConfig({
live: {
controls: {
thumbnails: {
media_type: 'recordings',
},
timeline: {
window_seconds: 3600,
},
},
},
}).live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).toBeCalledWith(
host,
cameraManager,
cardWideConfig,
view,
expect.objectContaining({
allCameras: false,
targetView: 'live',
select: 'latest',
viewContext: expect.objectContaining({
timeline: {
window: {
start: new Date('2024-04-07T18:43'),
end: now,
},
},
}),
}),
);
});
});
});
});
@@ -1,7 +1,6 @@
import endOfDay from 'date-fns/endOfDay'; import endOfDay from 'date-fns/endOfDay';
import startOfDay from 'date-fns/startOfDay'; import startOfDay from 'date-fns/startOfDay';
import sub from 'date-fns/sub'; import sub from 'date-fns/sub';
import { LitElement } from 'lit';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { Capabilities } from '../../src/camera-manager/capabilities'; import { Capabilities } from '../../src/camera-manager/capabilities';
import { CameraManagerStore } from '../../src/camera-manager/store'; import { CameraManagerStore } from '../../src/camera-manager/store';
@@ -23,6 +22,7 @@ import {
createCameraConfig, createCameraConfig,
createCameraManager, createCameraManager,
createCapabilities, createCapabilities,
createLitElement,
createPerformanceConfig, createPerformanceConfig,
createStore, createStore,
createView, createView,
@@ -30,12 +30,6 @@ import {
vi.mock('../../src/utils/media-to-view'); vi.mock('../../src/utils/media-to-view');
const createHost = (): LitElement => {
const host = document.createElement('div') as unknown as LitElement;
host.requestUpdate = vi.fn();
return host;
};
const createCameraStore = (options?: { const createCameraStore = (options?: {
capabilities: Capabilities; capabilities: Capabilities;
}): CameraManagerStore => { }): CameraManagerStore => {
@@ -64,7 +58,7 @@ describe('MediaFilterController', () => {
describe('should have correct default options', () => { describe('should have correct default options', () => {
it('media type', () => { it('media type', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getMediaTypeOptions()).toEqual([ expect(controller.getMediaTypeOptions()).toEqual([
{ {
value: MediaFilterMediaType.Clips, value: MediaFilterMediaType.Clips,
@@ -82,7 +76,7 @@ describe('MediaFilterController', () => {
}); });
it('favorite', () => { it('favorite', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getFavoriteOptions()).toEqual([ expect(controller.getFavoriteOptions()).toEqual([
{ {
value: MediaFilterCoreFavoriteSelection.Favorite, value: MediaFilterCoreFavoriteSelection.Favorite,
@@ -96,7 +90,7 @@ describe('MediaFilterController', () => {
}); });
it('when', () => { it('when', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getWhenOptions()).toEqual([ expect(controller.getWhenOptions()).toEqual([
{ {
value: MediaFilterCoreWhen.Today, value: MediaFilterCoreWhen.Today,
@@ -122,22 +116,22 @@ describe('MediaFilterController', () => {
}); });
it('cameras', () => { it('cameras', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getCameraOptions()).toEqual([]); expect(controller.getCameraOptions()).toEqual([]);
}); });
it('what', () => { it('what', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getWhatOptions()).toEqual([]); expect(controller.getWhatOptions()).toEqual([]);
}); });
it('where', () => { it('where', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getWhereOptions()).toEqual([]); expect(controller.getWhereOptions()).toEqual([]);
}); });
it('tags', () => { it('tags', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getTagsOptions()).toEqual([]); expect(controller.getTagsOptions()).toEqual([]);
}); });
}); });
@@ -152,7 +146,7 @@ describe('MediaFilterController', () => {
}); });
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
controller.computeCameraOptions(cameraManager); controller.computeCameraOptions(cameraManager);
expect(controller.getCameraOptions()).toEqual([ expect(controller.getCameraOptions()).toEqual([
{ {
@@ -178,7 +172,7 @@ describe('MediaFilterController', () => {
}), }),
); );
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
controller.computeCameraOptions(cameraManager); controller.computeCameraOptions(cameraManager);
expect(controller.getCameraOptions()).toEqual([]); expect(controller.getCameraOptions()).toEqual([]);
}); });
@@ -188,7 +182,7 @@ describe('MediaFilterController', () => {
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null);
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
controller.computeCameraOptions(cameraManager); controller.computeCameraOptions(cameraManager);
expect(controller.getCameraOptions()).toEqual([ expect(controller.getCameraOptions()).toEqual([
{ {
@@ -206,7 +200,7 @@ describe('MediaFilterController', () => {
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getMediaMetadata).mockRejectedValue(new Error('error')); vi.mocked(cameraManager.getMediaMetadata).mockRejectedValue(new Error('error'));
const host = createHost(); const host = createLitElement();
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
await controller.computeMetadataOptions(cameraManager); await controller.computeMetadataOptions(cameraManager);
expect(host.requestUpdate).not.toBeCalled(); expect(host.requestUpdate).not.toBeCalled();
@@ -218,7 +212,7 @@ describe('MediaFilterController', () => {
what: new Set(['person', 'car']), what: new Set(['person', 'car']),
}); });
const host = createHost(); const host = createLitElement();
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
await controller.computeMetadataOptions(cameraManager); await controller.computeMetadataOptions(cameraManager);
expect(controller.getWhatOptions()).toEqual([ expect(controller.getWhatOptions()).toEqual([
@@ -240,7 +234,7 @@ describe('MediaFilterController', () => {
where: new Set(['front_door', 'back_yard']), where: new Set(['front_door', 'back_yard']),
}); });
const host = createHost(); const host = createLitElement();
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
await controller.computeMetadataOptions(cameraManager); await controller.computeMetadataOptions(cameraManager);
expect(controller.getWhereOptions()).toEqual([ expect(controller.getWhereOptions()).toEqual([
@@ -262,7 +256,7 @@ describe('MediaFilterController', () => {
tags: new Set(['tag-1', 'tag-2']), tags: new Set(['tag-1', 'tag-2']),
}); });
const host = createHost(); const host = createLitElement();
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
await controller.computeMetadataOptions(cameraManager); await controller.computeMetadataOptions(cameraManager);
expect(controller.getTagsOptions()).toEqual([ expect(controller.getTagsOptions()).toEqual([
@@ -284,7 +278,7 @@ describe('MediaFilterController', () => {
days: new Set(['2024-02-04', '2024-02-05']), days: new Set(['2024-02-04', '2024-02-05']),
}); });
const host = createHost(); const host = createLitElement();
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
await controller.computeMetadataOptions(cameraManager); await controller.computeMetadataOptions(cameraManager);
@@ -306,7 +300,7 @@ describe('MediaFilterController', () => {
const view = createView({ query: new EventMediaQueries() }); const view = createView({ query: new EventMediaQueries() });
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
events: true, events: true,
recordings: false, recordings: false,
@@ -317,7 +311,7 @@ describe('MediaFilterController', () => {
const view = createView({ query: new RecordingMediaQueries() }); const view = createView({ query: new RecordingMediaQueries() });
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
events: false, events: false,
recordings: true, recordings: true,
@@ -331,7 +325,7 @@ describe('MediaFilterController', () => {
createCapabilities({ 'favorite-events': true }), createCapabilities({ 'favorite-events': true }),
); );
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
favorites: true, favorites: true,
}); });
@@ -344,7 +338,7 @@ describe('MediaFilterController', () => {
createCapabilities({ 'favorite-recordings': true }), createCapabilities({ 'favorite-recordings': true }),
); );
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
favorites: true, favorites: true,
}); });
@@ -354,7 +348,7 @@ describe('MediaFilterController', () => {
const view = createView(); const view = createView();
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
favorites: false, favorites: false,
}); });
@@ -363,7 +357,7 @@ describe('MediaFilterController', () => {
describe('should handle value change', () => { describe('should handle value change', () => {
it('must have visible cameras', async () => { it('must have visible cameras', async () => {
const host = createHost(); const host = createLitElement();
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
await controller.valueChangeHandler( await controller.valueChangeHandler(
createCameraManager(), createCameraManager(),
@@ -380,7 +374,7 @@ describe('MediaFilterController', () => {
'%s', '%s',
async (viewName: 'clips' | 'snapshots') => { async (viewName: 'clips' | 'snapshots') => {
const eventListener = vi.fn(); const eventListener = vi.fn();
const host = createHost(); const host = createLitElement();
host.addEventListener('frigate-card:view:change', eventListener); host.addEventListener('frigate-card:view:change', eventListener);
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
@@ -458,7 +452,7 @@ describe('MediaFilterController', () => {
it('with recordings media type', async () => { it('with recordings media type', async () => {
const eventListener = vi.fn(); const eventListener = vi.fn();
const host = createHost(); const host = createLitElement();
host.addEventListener('frigate-card:view:change', eventListener); host.addEventListener('frigate-card:view:change', eventListener);
const controller = new MediaFilterController(host); const controller = new MediaFilterController(host);
@@ -522,7 +516,7 @@ describe('MediaFilterController', () => {
}); });
it('without favorites', async () => { it('without favorites', async () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
@@ -583,7 +577,7 @@ describe('MediaFilterController', () => {
new Date('2024-02-29T23:59:59.999'), new Date('2024-02-29T23:59:59.999'),
], ],
])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => { ])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
@@ -614,7 +608,7 @@ describe('MediaFilterController', () => {
}); });
it('custom without values', async () => { it('custom without values', async () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
@@ -646,7 +640,7 @@ describe('MediaFilterController', () => {
describe('should calculate correct defaults', () => { describe('should calculate correct defaults', () => {
it('with no queries', () => { it('with no queries', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
controller.computeInitialDefaultsFromView(createCameraManager(), createView()); controller.computeInitialDefaultsFromView(createCameraManager(), createView());
@@ -654,7 +648,7 @@ describe('MediaFilterController', () => {
}); });
it('with no cameras', () => { it('with no cameras', () => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
controller.computeInitialDefaultsFromView( controller.computeInitialDefaultsFromView(
createCameraManager(), createCameraManager(),
@@ -956,7 +950,7 @@ describe('MediaFilterController', () => {
mediaQueries: MediaQueries, mediaQueries: MediaQueries,
defaults: MediaFilterCoreDefaults | null, defaults: MediaFilterCoreDefaults | null,
) => { ) => {
const controller = new MediaFilterController(createHost()); const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
+21 -31
View File
@@ -1,25 +1,15 @@
import { import { HASSDomEvent, handleActionConfig } from '@dermotduffy/custom-card-helpers';
HASSDomEvent,
handleActionConfig,
} from '@dermotduffy/custom-card-helpers';
import { LitElement } from 'lit';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FRIGATE_ICON_SVG_PATH } from '../../src/camera-manager/frigate/icon'; import { FRIGATE_ICON_SVG_PATH } from '../../src/camera-manager/frigate/icon';
import { MenuController } from '../../src/components-lib/menu-controller'; import { MenuController } from '../../src/components-lib/menu-controller';
import { ActionsConfig, MenuConfig, menuConfigSchema } from '../../src/config/types'; import { ActionsConfig, MenuConfig, menuConfigSchema } from '../../src/config/types';
import { StateParameters } from '../../src/types'; import { StateParameters } from '../../src/types';
import { refreshDynamicStateParameters } from '../../src/utils/ha'; import { refreshDynamicStateParameters } from '../../src/utils/ha';
import { createHASS } from '../test-utils'; import { createHASS, createLitElement } from '../test-utils';
vi.mock('@dermotduffy/custom-card-helpers'); vi.mock('@dermotduffy/custom-card-helpers');
vi.mock('../../src/utils/ha'); vi.mock('../../src/utils/ha');
const createHost = (): LitElement => {
const host = document.createElement('div') as unknown as LitElement;
host.requestUpdate = vi.fn();
return host;
};
const createMenuConfig = (config: unknown): MenuConfig => { const createMenuConfig = (config: unknown): MenuConfig => {
return menuConfigSchema.parse(config); return menuConfigSchema.parse(config);
}; };
@@ -59,7 +49,7 @@ describe('MenuController', () => {
}); });
it('should set and get menu config', () => { it('should set and get menu config', () => {
const host = createHost(); const host = createLitElement();
const controller = new MenuController(host); const controller = new MenuController(host);
const config = createMenuConfig({ const config = createMenuConfig({
@@ -79,7 +69,7 @@ describe('MenuController', () => {
describe('should set and sort buttons', () => { describe('should set and sort buttons', () => {
it('by priority', () => { it('by priority', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
controller.setButtons([ controller.setButtons([
{ {
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
@@ -146,7 +136,7 @@ describe('MenuController', () => {
}); });
it('with frigate button first', () => { it('with frigate button first', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
controller.setMenuConfig( controller.setMenuConfig(
createMenuConfig({ createMenuConfig({
style: 'hidden', style: 'hidden',
@@ -197,7 +187,7 @@ describe('MenuController', () => {
describe('should get buttons', () => { describe('should get buttons', () => {
it('with matching alignment', () => { it('with matching alignment', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
controller.setButtons([ controller.setButtons([
{ {
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
@@ -229,7 +219,7 @@ describe('MenuController', () => {
}); });
it('with disabled buttons', () => { it('with disabled buttons', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
controller.setButtons([ controller.setButtons([
{ {
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
@@ -261,7 +251,7 @@ describe('MenuController', () => {
}); });
it('with hidden non-expanded menu', () => { it('with hidden non-expanded menu', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
controller.setMenuConfig( controller.setMenuConfig(
createMenuConfig({ createMenuConfig({
style: 'hidden', style: 'hidden',
@@ -303,7 +293,7 @@ describe('MenuController', () => {
describe('should get fresh button state', () => { describe('should get fresh button state', () => {
it('on state icon', () => { it('on state icon', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
const stateButton = { const stateButton = {
type: 'custom:frigate-card-menu-state-icon' as const, type: 'custom:frigate-card-menu-state-icon' as const,
icon: 'mdi:sheep', icon: 'mdi:sheep',
@@ -322,7 +312,7 @@ describe('MenuController', () => {
}); });
it('on non state icon', () => { it('on non state icon', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
const button = { const button = {
type: 'custom:frigate-card-menu-icon' as const, type: 'custom:frigate-card-menu-icon' as const,
icon: 'mdi:sheep', icon: 'mdi:sheep',
@@ -335,7 +325,7 @@ describe('MenuController', () => {
describe('should get svg path', () => { describe('should get svg path', () => {
it('frigate icon', () => { it('frigate icon', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
const button = { const button = {
type: 'custom:frigate-card-menu-icon' as const, type: 'custom:frigate-card-menu-icon' as const,
icon: 'frigate', icon: 'frigate',
@@ -344,7 +334,7 @@ describe('MenuController', () => {
}); });
it('non-frigate icon', () => { it('non-frigate icon', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
const button = { const button = {
type: 'custom:frigate-card-menu-icon' as const, type: 'custom:frigate-card-menu-icon' as const,
icon: 'mdi:cow', icon: 'mdi:cow',
@@ -355,13 +345,13 @@ describe('MenuController', () => {
describe('should handle actions', () => { describe('should handle actions', () => {
it('should bail without config', () => { it('should bail without config', () => {
const controller = new MenuController(createHost()); const controller = new MenuController(createLitElement());
controller.actionHandler(createHASS(), createEvent('tap')); controller.actionHandler(createHASS(), createEvent('tap'));
expect(vi.mocked(handleActionConfig)).not.toBeCalled(); expect(vi.mocked(handleActionConfig)).not.toBeCalled();
}); });
it('should execute simple action in non-hidden menu', () => { it('should execute simple action in non-hidden menu', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
@@ -377,7 +367,7 @@ describe('MenuController', () => {
}); });
it('should execute simple action in with config in event', () => { it('should execute simple action in with config in event', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
@@ -391,7 +381,7 @@ describe('MenuController', () => {
}); });
it('should execute simple array of actions in non-hidden menu', () => { it('should execute simple array of actions in non-hidden menu', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
@@ -401,7 +391,7 @@ describe('MenuController', () => {
describe('should close menu', () => { describe('should close menu', () => {
it('tap', () => { it('tap', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
controller.setMenuConfig( controller.setMenuConfig(
@@ -418,7 +408,7 @@ describe('MenuController', () => {
}); });
it('end_tap', () => { it('end_tap', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
controller.setMenuConfig( controller.setMenuConfig(
@@ -439,7 +429,7 @@ describe('MenuController', () => {
describe('should not close menu', () => { describe('should not close menu', () => {
it('start_tap with later action', () => { it('start_tap with later action', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
controller.setMenuConfig( controller.setMenuConfig(
@@ -459,7 +449,7 @@ describe('MenuController', () => {
}); });
it('with a menu toggle action', () => { it('with a menu toggle action', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
controller.setMenuConfig( controller.setMenuConfig(
@@ -479,7 +469,7 @@ describe('MenuController', () => {
}); });
it('when no action is actually taken', () => { it('when no action is actually taken', () => {
const host = createHost(); const host = createLitElement();
const hass = createHASS(); const hass = createHASS();
const controller = new MenuController(host); const controller = new MenuController(host);
controller.setMenuConfig( controller.setMenuConfig(
+40 -2
View File
@@ -1,5 +1,6 @@
import { CurrentUser, HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { CurrentUser, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { LitElement } from 'lit';
import { expect, vi } from 'vitest'; import { expect, vi } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { Camera } from '../src/camera-manager/camera'; import { Camera } from '../src/camera-manager/camera';
@@ -13,7 +14,6 @@ import {
CameraEventCallback, CameraEventCallback,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
} from '../src/camera-manager/types'; } from '../src/camera-manager/types';
import { CapabilitiesRaw } from '../src/types';
import { ActionsManager } from '../src/card-controller/actions-manager'; import { ActionsManager } from '../src/card-controller/actions-manager';
import { AutoUpdateManager } from '../src/card-controller/auto-update-manager'; import { AutoUpdateManager } from '../src/card-controller/auto-update-manager';
import { AutomationsManager } from '../src/card-controller/automations-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager';
@@ -47,7 +47,7 @@ import {
frigateCardConfigSchema, frigateCardConfigSchema,
performanceConfigSchema, performanceConfigSchema,
} from '../src/config/types'; } from '../src/config/types';
import { ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types';
import { EntityRegistryManager } from '../src/utils/ha/entity-registry'; import { EntityRegistryManager } from '../src/utils/ha/entity-registry';
import { Entity } from '../src/utils/ha/entity-registry/types'; import { Entity } from '../src/utils/ha/entity-registry/types';
import { ViewMedia, ViewMediaType } from '../src/view/media'; import { ViewMedia, ViewMediaType } from '../src/view/media';
@@ -248,6 +248,24 @@ export const createMediaLoadedInfo = (
}; };
}; };
export const createMediaLoadedInfoEvent = (
mediaLoadedInfo?: MediaLoadedInfo,
): CustomEvent<MediaLoadedInfo> => {
return new CustomEvent('frigate-card:media:loaded', {
detail: mediaLoadedInfo ?? createMediaLoadedInfo(),
composed: true,
bubbles: true,
});
};
export const createViewChangeEvent = (view?: View): CustomEvent<View> => {
return new CustomEvent('frigate-card:view:change', {
detail: view ?? createView(),
composed: true,
bubbles: true,
});
};
export const createPerformanceConfig = (config: unknown): PerformanceConfig => { export const createPerformanceConfig = (config: unknown): PerformanceConfig => {
return performanceConfigSchema.parse(config); return performanceConfigSchema.parse(config);
}; };
@@ -342,6 +360,20 @@ export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
return 1; return 1;
}; };
export const callIntersectionHandler = (intersecting = true, n = 0): void => {
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(IntersectionObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid IntersectionObserverEntry that
// just provides the bare basics current implementation uses.
intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [],
observer,
);
};
export const createSlotHost = (options?: { export const createSlotHost = (options?: {
slot?: HTMLSlotElement; slot?: HTMLSlotElement;
children?: HTMLElement[]; children?: HTMLElement[];
@@ -370,6 +402,12 @@ export const createParent = (options?: { children?: HTMLElement[] }): HTMLElemen
return parent; return parent;
}; };
export const createLitElement = (): LitElement => {
const element = document.createElement('div') as unknown as LitElement;
element.requestUpdate = vi.fn();
return element;
};
export const createCardAPI = (): CardController => { export const createCardAPI = (): CardController => {
const api = mock<CardController>(); const api = mock<CardController>();
@@ -20,12 +20,12 @@ import {
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../../../src/utils/media-info'; import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../../../src/utils/media-info';
import { import {
IntersectionObserverMock, IntersectionObserverMock,
callIntersectionHandler,
createMediaLoadedInfo, createMediaLoadedInfo,
createParent, createParent,
} from '../../../../test-utils'; } from '../../../../test-utils';
import { import {
callEmblaHandler, callEmblaHandler,
callIntersectionHandler,
callVisibilityHandler, callVisibilityHandler,
createEmblaApiInstance, createEmblaApiInstance,
createTestEmblaOptionHandler, createTestEmblaOptionHandler,
@@ -3,12 +3,12 @@ import AutoSize from '../../../../../src/utils/embla/plugins/auto-size/auto-size
import { import {
IntersectionObserverMock, IntersectionObserverMock,
ResizeObserverMock, ResizeObserverMock,
callIntersectionHandler,
createParent, createParent,
requestAnimationFrameMock, requestAnimationFrameMock,
} from '../../../../test-utils'; } from '../../../../test-utils';
import { import {
callEmblaHandler, callEmblaHandler,
callIntersectionHandler,
callResizeHandler, callResizeHandler,
createEmblaApiInstance, createEmblaApiInstance,
createTestEmblaOptionHandler, createTestEmblaOptionHandler,
-14
View File
@@ -43,20 +43,6 @@ export const callVisibilityHandler = (): void => {
} }
}; };
export const callIntersectionHandler = (intersecting = true, n = 0): void => {
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(IntersectionObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid IntersectionObserverEntry that
// just provides the bare basics current implementation uses.
intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [],
observer,
);
};
export const callMutationHandler = (n = 0): void => { export const callMutationHandler = (n = 0): void => {
const mockResult = vi.mocked(MutationObserver).mock.results[n]; const mockResult = vi.mocked(MutationObserver).mock.results[n];
if (mockResult.type !== 'return') { if (mockResult.type !== 'return') {
+1
View File
@@ -13,6 +13,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
'camera-manager/utils/*.ts', 'camera-manager/utils/*.ts',
'card-controller/*.ts', 'card-controller/*.ts',
'components-lib/cached-value-controller.ts', 'components-lib/cached-value-controller.ts',
'components-lib/live/live-controller.ts',
'components-lib/media-filter-controller.ts', 'components-lib/media-filter-controller.ts',
'components-lib/menu-button-controller.ts', 'components-lib/menu-button-controller.ts',
'components-lib/menu-controller.ts', 'components-lib/menu-controller.ts',