-
- ${this._cameras === undefined
- ? until(
- (async () => {
- await this._loadCameras();
- // Don't reset messages as errors may have been generated
- // during the camera load.
- this._changeView({ resetMessage: false });
- return this._render();
- })(),
- renderProgressIndicator(),
- )
- : // Always want to call render even if there's a message, to
- // ensure live preload is always present (even if not displayed).
- this._render()}
- ${this._getConfig().elements
- ? // Always show elements to allow for custom menu items (etc.) to
- // be present even if a particular view has an error. Elements
- // need to render after the main views so it can render 'on top'.
- html` {
- this._addDynamicMenuButton(e.detail);
- }}
- @frigate-card:menu-remove=${(e) => {
- this._removeDynamicMenuButton(e.detail);
- }}
- @frigate-card:condition-state-request=${(ev) => {
- conditionStateRequestHandler(ev, this._conditionState);
- }}
- >
- `
- : ``}
- ${
- // Keep message rendering to last to show messages that may have
- // been generated during the render.
- this._message ? renderMessage(this._message) : ''
- }
-
+ ${renderMenuAbove ? this._renderMenu() : ''}
+
+ ${this._cameras === undefined && !this._message
+ ? until(
+ (async () => {
+ await this._loadCameras();
+ // Don't reset messages as errors may have been generated
+ // during the camera load.
+ this._changeView({ resetMessage: false });
+ return this._render();
+ })(),
+ renderProgressIndicator(),
+ )
+ : // Always want to call render even if there's a message, to
+ // ensure live preload is always present (even if not displayed).
+ this._render()}
+ ${
+ // Keep message rendering to last to show messages that may have
+ // been generated during the render.
+ this._message ? renderMessage(this._message) : ''
+ }
- ${this._getConfig().menu.mode != 'above' ? this._renderMenu() : ''}
+ ${!renderMenuAbove ? this._renderMenu() : ''}
+ ${this._getConfig().elements
+ ? // Elements need to render after the main views so it can render 'on
+ // top'.
+ html`
{
+ this._addDynamicMenuButton(e.detail);
+ }}
+ @frigate-card:menu-remove=${(e) => {
+ this._removeDynamicMenuButton(e.detail);
+ }}
+ @frigate-card:condition-state-request=${(ev) => {
+ conditionStateRequestHandler(ev, this._conditionState);
+ }}
+ >
+ `
+ : ``}
`;
}
@@ -1234,6 +1891,7 @@ export class FrigateCard extends LitElement {
return html`
${!this._message && this._view.is('image')
? html`
`
@@ -1254,12 +1912,22 @@ export class FrigateCard extends LitElement {
? html`
`
: ``}
+ ${!this._message && this._view.is('timeline')
+ ? html`
+ `
+ : ``}
${
// Note: Subtle difference in condition below vs the other views in order
// to always render the live view for live.preload mode.
@@ -1270,13 +1938,13 @@ export class FrigateCard extends LitElement {
this._getConfig().live.preload || (!this._message && this._view.is('live'))
? html`
@@ -1298,9 +1966,15 @@ export class FrigateCard extends LitElement {
* @returns The Lovelace card size in units of 50px.
*/
public getCardSize(): number {
- if (this._mediaShowInfo) {
- return this._mediaShowInfo.height / 50;
+ if (this._lastValidMediaLoadedInfo) {
+ return this._lastValidMediaLoadedInfo.height / 50;
}
return 6;
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card': FrigateCard;
+ }
+}
diff --git a/src/common.ts b/src/common.ts
deleted file mode 100644
index 77eb7e30..00000000
--- a/src/common.ts
+++ /dev/null
@@ -1,591 +0,0 @@
-import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
-import {
- HomeAssistant,
- computeStateDomain,
- handleActionConfig,
- hasAction,
- stateIcon,
-} from 'custom-card-helpers';
-import { StyleInfo } from 'lit/directives/style-map.js';
-import { ZodSchema, z } from 'zod';
-import { isEqual } from 'lodash-es';
-
-import { localize } from './localize/localize.js';
-import {
- Actions,
- ActionsConfig,
- ActionType,
- CameraConfig,
- ExtendedHomeAssistant,
- FrigateCardAction,
- FrigateCardCustomAction,
- frigateCardCustomActionSchema,
- MediaShowInfo,
- Message,
- SignedPath,
- signedPathSchema,
- StateParameters,
-} from './types.js';
-
-const MEDIA_INFO_HEIGHT_CUTOFF = 50;
-const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
-
-/**
- * Get the keys that didn't parse from a ZodError.
- * @param error The zoderror to extract the keys from.
- * @returns An array of error keys.
- */
-export function getParseErrorKeys(error: z.ZodError): string[] {
- const errors = error.format();
- return Object.keys(errors).filter((v) => !v.startsWith('_'));
-}
-
-/**
- * Make a HomeAssistant websocket request. May throw.
- * @param hass The HomeAssistant object to send the request with.
- * @param schema The expected Zod schema of the response.
- * @param request The request to make.
- * @returns The parsed valid response or null on malformed.
- */
-export async function homeAssistantWSRequest(
- hass: HomeAssistant & ExtendedHomeAssistant,
- schema: ZodSchema,
- request: MessageBase,
-): Promise {
- const response = await hass.callWS(request);
-
- if (!response) {
- const error_message = `${localize('error.empty_response')}: ${JSON.stringify(
- request,
- )}`;
- console.warn(error_message);
- throw new Error(error_message);
- }
- const parseResult = schema.safeParse(response);
- if (!parseResult.success) {
- const keys = getParseErrorKeys(parseResult.error);
- const error_message =
- `${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
- localize('error.invalid_keys') +
- `: '${keys}'`;
- console.warn(error_message);
- throw new Error(error_message);
- }
- return parseResult.data;
-}
-
-/**
- * Request that HA sign a path. May throw.
- * @param hass The HomeAssistant object used to request the signature.
- * @param path The path to sign.
- * @param expires An optional number of seconds to sign the path for.
- * @returns The signed URL, or null if the response was malformed.
- */
-export async function homeAssistantSignPath(
- hass: HomeAssistant & ExtendedHomeAssistant,
- path: string,
- expires?: number,
-): Promise {
- const request = {
- type: 'auth/sign_path',
- path: path,
- expires: expires,
- };
- const response = await homeAssistantWSRequest(
- hass,
- signedPathSchema,
- request,
- );
- if (!response) {
- return null;
- }
- return hass.hassUrl(response.path);
-}
-
-/**
- * Dispatch a Frigate Card event.
- * @param element The element to send the event.
- * @param name The name of the Frigate card event to send.
- * @param detail An optional detail object to attach.
- */
-export function dispatchFrigateCardEvent(
- element: HTMLElement,
- name: string,
- detail?: T,
-): void {
- element.dispatchEvent(
- new CustomEvent(`frigate-card:${name}`, {
- bubbles: true,
- composed: true,
- detail: detail,
- }),
- );
-}
-
-/**
- * Create a MediaShowInfo object.
- * @param source An event or HTMLElement that should be used as a source.
- * @returns A new MediaShowInfo object or null if one could not be created.
- */
-export function createMediaShowInfo(source: Event | HTMLElement): MediaShowInfo | null {
- let target: HTMLElement | EventTarget;
- if (source instanceof Event) {
- target = source.composedPath()[0];
- } else {
- target = source;
- }
-
- if (target instanceof HTMLImageElement) {
- return {
- width: (target as HTMLImageElement).naturalWidth,
- height: (target as HTMLImageElement).naturalHeight,
- };
- } else if (target instanceof HTMLVideoElement) {
- return {
- width: (target as HTMLVideoElement).videoWidth,
- height: (target as HTMLVideoElement).videoHeight,
- };
- } else if (target instanceof HTMLCanvasElement) {
- return {
- width: (target as HTMLCanvasElement).width,
- height: (target as HTMLCanvasElement).height,
- };
- }
- return null;
-}
-
-/**
- * Dispatch a Frigate card media show event.
- * @param element The element to send the event.
- * @param source An event or HTMLElement that should be used as a source.
- */
-export function dispatchMediaShowEvent(
- element: HTMLElement,
- source: Event | HTMLElement,
-): void {
- const mediaShowInfo = createMediaShowInfo(source);
- if (mediaShowInfo) {
- dispatchExistingMediaShowInfoAsEvent(element, mediaShowInfo);
- }
-}
-
-/**
- * Dispatch a pre-existing MediaShowInfo object as an event.
- * @param element The element to send the event.
- * @param mediaShowInfo The MediaShowInfo object to send.
- */
-export function dispatchExistingMediaShowInfoAsEvent(
- element: HTMLElement,
- mediaShowInfo: MediaShowInfo,
-): void {
- dispatchFrigateCardEvent(element, 'media-show', mediaShowInfo);
-}
-
-/**
- * Dispatch an event with a message to show to the user.
- * @param element The element to send the event.
- * @param message The message to show.
- * @param icon An optional icon to attach to the message.
- */
-export function dispatchMessageEvent(
- element: HTMLElement,
- message: string,
- icon?: string,
- context?: unknown,
-): void {
- dispatchFrigateCardEvent(element, 'message', {
- message: message,
- type: 'info',
- icon: icon,
- context: context,
- });
-}
-
-/**
- * Dispatch an event with an error message to show to the user.
- * @param element The element to send the event.
- * @param message The message to show.
- */
-export function dispatchErrorMessageEvent(
- element: HTMLElement,
- message: string,
- context?: unknown,
-): void {
- dispatchFrigateCardEvent(element, 'message', {
- message: message,
- type: 'error',
- context: context,
- });
-}
-
-/**
- * Determine whether the card should be updated based on Home Assistant changes.
- * @param newHass The new HA object.
- * @param oldHass The old HA object.
- * @param entities The entities to examine for changes.
- * @returns A boolean indicating whether or not to allow an update.
- */
-export function shouldUpdateBasedOnHass(
- newHass: HomeAssistant | undefined | null,
- oldHass: HomeAssistant | undefined | null,
- entities: string[] | null,
-): boolean {
- if (!newHass || !entities || !entities.length) {
- return false;
- }
- if (!oldHass) {
- return true;
- }
-
- for (let i = 0; i < entities.length; i++) {
- const entity = entities[i];
- if (entity && oldHass.states[entity] !== newHass.states[entity]) {
- return true;
- }
- }
- return false;
-}
-
-/**
- * Determine if a MediaShowInfo object is valid/acceptable.
- * @param info The MediaShowInfo object.
- * @returns True if the object is valid, false otherwise.
- */
-export function isValidMediaShowInfo(info: MediaShowInfo): boolean {
- return (
- info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
- );
-}
-
-/**
- * Convert a generic Action to a FrigateCardCustomAction if it parses correctly.
- * @param action The generic action configuration.
- * @returns A FrigateCardCustomAction or null if it cannot be converted.
- */
-export function convertActionToFrigateCardCustomAction(
- action: ActionType | null,
-): FrigateCardCustomAction | null {
- if (!action) {
- return null;
- }
- // Parse a custom event as other things could generate ll-custom events that
- // are not related to Frigate Card.
- const parseResult = frigateCardCustomActionSchema.safeParse(action);
- return parseResult.success ? parseResult.data : null;
-}
-
-/**
- * Create a Frigate card custom action.
- * @param action The Frigate card action string (e.g. 'fullscreen')
- * @returns A FrigateCardCustomAction for that action string.
- */
-export function createFrigateCardCustomAction(
- action: FrigateCardAction,
- camera?: string,
-): FrigateCardCustomAction | undefined {
- if (action == 'camera_select') {
- if (!camera) {
- return undefined;
- }
- return {
- action: 'fire-dom-event',
- frigate_card_action: action,
- camera: camera,
- };
- }
- return {
- action: 'fire-dom-event',
- frigate_card_action: action,
- };
-}
-
-/**
- * Get an action configuration given a config and an interaction (e.g. 'tap').
- * @param interaction The interaction: `tap`, `hold` or `double_tap`
- * @param config The configuration containing multiple actions.
- * @returns The relevant action configuration or null if none found.
- */
-export function getActionConfigGivenAction(
- interaction?: string,
- config?: Actions,
-): ActionType | ActionType[] | undefined {
- if (!interaction || !config) {
- return undefined;
- }
- if (interaction == 'tap' && config.tap_action) {
- return config.tap_action;
- } else if (interaction == 'hold' && config.hold_action) {
- return config.hold_action;
- } else if (interaction == 'double_tap' && config.double_tap_action) {
- return config.double_tap_action;
- } else if (interaction == 'end_tap' && config.end_tap_action) {
- return config.end_tap_action;
- } else if (interaction == 'start_tap' && config.start_tap_action) {
- return config.start_tap_action;
- }
- return undefined;
-}
-
-/**
- * Calculate a style brightness from a hass state.
- * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L296
- * @param state The hass state object.
- * @returns A CSS brightness string.
- */
-function computeBrightnessFromState(state: HassEntity): string {
- if (state.state === 'off' || !state.attributes.brightness) {
- return '';
- }
- const brightness = state.attributes.brightness;
- return `brightness(${(brightness + 245) / 5}%)`;
-}
-
-/**
- * Calculate a style color from a hass state.
- * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L304
- * @param state The hass state object.
- * @returns A CSS color string.
- */
-function computeColorFromState(state: HassEntity): string {
- if (state.state === 'off') {
- return '';
- }
- return state.attributes.rgb_color
- ? `rgb(${state.attributes.rgb_color.join(',')})`
- : '';
-}
-
-/**
- * Get the style of emphasized menu items.
- * @returns A StyleInfo.
- */
-function computeStyle(state: HassEntity): StyleInfo {
- return {
- color: computeColorFromState(state),
- filter: computeBrightnessFromState(state),
- };
-}
-
-/**
- * Determine the string state of a given stateObj.
- * From: https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_active_state.ts
- * @param stateObj The HassEntity object from `hass.states`.
- * @returns A string state, e.g. 'on'.
- */
-export const computeActiveState = (stateObj: HassEntity): string => {
- const domain = stateObj.entity_id.split('.')[0];
- let state = stateObj.state;
-
- if (domain === 'climate') {
- state = stateObj.attributes.hvac_action;
- }
-
- return state;
-};
-
-/**
- * Use Home Assistant state to refresh state parameters for an item to be rendered.
- * @param hass Home Assistant object.
- * @param params A StateParameters object to modify in place.
- * @returns A StateParameters object updated based on HASS state.
- */
-export function refreshDynamicStateParameters(
- hass: HomeAssistant,
- params: StateParameters,
-): StateParameters {
- if (!params.entity) {
- return params;
- }
- const state = hass.states[params.entity];
- if (!!state && !!params.state_color) {
- params.style = { ...computeStyle(state), ...params.style };
- }
- params.title = params.title ?? (state?.attributes?.friendly_name || params.entity);
- params.icon = params.icon ?? stateIcon(state);
-
- const domain = state ? computeStateDomain(state) : undefined;
- params.data_domain =
- params.state_color || (domain === 'light' && params.state_color !== false)
- ? domain
- : undefined;
- if (state) {
- params.data_state = computeActiveState(state);
- }
- return params;
-}
-
-/**
- * Prettify a Frigate name by converting '_' to spaces and capitalizing words.
- * @param input The input Frigate (camera/label/zone) name.
- * @returns A prettified name.
- */
-export function prettifyFrigateName(input?: string): string | undefined {
- if (!input) {
- return undefined;
- }
- const words = input.split(/[_\s]+/);
- return words
- .map((word) => {
- return word[0].toUpperCase() + word.substring(1);
- })
- .join(' ');
-}
-
-/**
- * Get the title of an entity.
- * @param entity The entity id.
- * @param hass The Home Assistant object.
- * @returns The title or undefined.
- */
-export function getEntityTitle(
- hass?: HomeAssistant,
- entity?: string,
-): string | undefined {
- return entity ? hass?.states[entity]?.attributes?.friendly_name : undefined;
-}
-
-/**
- * Get the icon of an entity.
- * @param entity The entity id.
- * @param hass The Home Assistant object.
- * @returns The icon or undefined.
- */
-export function getEntityIcon(
- hass?: HomeAssistant,
- entity?: string,
-): string | undefined {
- return hass && entity ? stateIcon(hass.states[entity]) : undefined;
-}
-
-/**
- * Get a camera text title.
- * @param hass The Home Assistant object.
- * @param config The camera config.
- * @returns A title string.
- */
-export function getCameraTitle(
- hass?: HomeAssistant,
- config?: CameraConfig | null,
-): string {
- return (
- config?.title ||
- (config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') ||
- (config?.camera_name ? prettifyFrigateName(config.camera_name) : '') ||
- ''
- );
-}
-
-/**
- * Get a camera icon.
- * @param hass The Home Assistant object.
- * @param config The camera config.
- * @returns An icon string.
- */
-export function getCameraIcon(
- hass?: HomeAssistant,
- config?: CameraConfig | null,
-): string {
- return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video';
-}
-
-/**
- * Move an element within an array.
- * @param target Target array.
- * @param from From index.
- * @param to To index.
- */
-export function arrayMove(target: unknown[], from: number, to: number): void {
- const element = target[from];
- target.splice(from, 1);
- target.splice(to, 0, element);
-}
-
-/**
- * Determine if the contents of the n(ew) and o(ld) values have changed. For use
- * in lit web components that may have a value that changes address but not
- * contents -- and for which a re-render is expensive/jarring.
- * @param n The new value.
- * @param o The old value.
- * @returns `true` is the contents have changed.
- */
-export function contentsChanged(n: unknown, o: unknown): boolean {
- return !isEqual(n, o);
-}
-
-/**
- * Frigate card custom version of handleAction
- * (https://github.com/custom-cards/custom-card-helpers/blob/master/src/handle-action.ts)
- * that handles the custom action events the card supports.
- * @param node The node that fired the event.
- * @param hass The Home Assistant object.
- * @param config The multi-action configuration.
- * @param action The action string (e.g. 'hold')
- * @returns Whether or not an action was executed.
- */
-export const frigateCardHandleAction = (
- node: HTMLElement,
- hass: HomeAssistant,
- config: ActionsConfig,
- action: string,
-): boolean => {
- return frigateCardHandleActionConfig(
- node,
- hass,
- config,
- action,
- getActionConfigGivenAction(action, config),
- );
-};
-
-/**
- * Handle an ActionConfig or array of ActionConfigs.
- * @param node The node that fired the event.
- * @param hass The Home Assistant object.
- * @param actionConfig A single action config, array of action configs or
- * undefined for the default action config for 'tap'.
- * @param action The action string (e.g. 'hold')
- * @returns Whether or not an action was executed.
- */
-export const frigateCardHandleActionConfig = (
- node: HTMLElement,
- hass: HomeAssistant,
- config: {
- camera_image?: string;
- entity?: string;
- },
- action: string,
- actionConfig: ActionType | ActionType[] | undefined,
-): boolean => {
- if (actionConfig || action == 'tap') {
- // Only allow a tap action to use a default non-config (the more-info config).
- if (Array.isArray(actionConfig)) {
- actionConfig.forEach((action) => handleActionConfig(node, hass, config, action));
- } else {
- handleActionConfig(node, hass, config, actionConfig);
- }
- return true;
- }
- return false;
-};
-
-/**
- * Determine if an action config has a real action. A modified version of
- * custom-card-helpers hasAction to also work with arrays of action configs.
- * @param config The action config in question.
- * @returns `true` if there's a real action defined, `false` otherwise.
- */
-export const frigateCardHasAction = (
- config?: ActionType | ActionType[] | undefined,
-): boolean => {
- if (Array.isArray(config)) {
- return !!config.find((item) => hasAction(item));
- }
- return hasAction(config);
-};
-
-/**
- * Stop an event from activating card wide actions.
- */
-export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
- ev.stopPropagation();
-};
diff --git a/src/components/carousel.ts b/src/components/carousel.ts
index 217506d9..4a018780 100644
--- a/src/components/carousel.ts
+++ b/src/components/carousel.ts
@@ -1,37 +1,197 @@
-import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit';
-import EmblaCarousel, {
- EmblaCarouselType,
- EmblaOptionsType,
- EmblaPluginType,
-} from 'embla-carousel';
-
-import { TransitionEffect } from '../types';
-import { dispatchFrigateCardEvent } from '../common';
-
+import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel';
+import { EmblaNodesType } from 'embla-carousel/components';
+import {
+ CreatePluginType,
+ EmblaPluginsType,
+ LoosePluginType,
+} from 'embla-carousel/components/Plugins';
+import {
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { createRef, ref, Ref } from 'lit/directives/ref.js';
+import { throttle } from 'lodash-es';
import carouselStyle from '../scss/carousel.scss';
+import { TransitionEffect } from '../types';
+import { dispatchFrigateCardEvent } from '../utils/basic.js';
export interface CarouselSelect {
index: number;
+ element: HTMLElement;
}
+export type EmblaCarouselPlugins = CreatePluginType<
+ LoosePluginType,
+ Record
+>[];
+
+@customElement('frigate-card-carousel')
export class FrigateCardCarousel extends LitElement {
+ @property({ attribute: true, reflect: true })
+ public direction: 'vertical' | 'horizontal' = 'horizontal';
+
+ @property({ attribute: false })
+ public carouselOptions?: EmblaOptionsType;
+
+ @property({ attribute: false })
+ public carouselPlugins?: EmblaCarouselPlugins;
+
+ @property({ attribute: true })
+ public transitionEffect?: TransitionEffect;
+
+ // An override to the startIndex, used to preserve the current carousel
+ // position after the carousel is destroyed (so it can be restored if
+ // recreated).
+ // See: https://github.com/dermotduffy/frigate-hass-card/issues/775
+ protected _savedStartIndex: number | null = null;
+
+ protected _refSlot: Ref = createRef();
+
protected _carousel?: EmblaCarouselType;
- protected _plugins: Record = {};
+
+ // Whether the carousel is actively scrolling.
+ protected _scrolling = false;
+
+ // Whether to reinit the carousel when it settles.
+ protected _reInitOnSettle = false;
+
+ protected _carouselReInitInPlace = throttle(
+ this._carouselReInitInPlaceInternal.bind(this),
+ 500,
+ { trailing: true },
+ );
+
+ connectedCallback(): void {
+ super.connectedCallback();
+
+ // Guarantee a re-render if the component is reconnected. See note in
+ // disconnectedCallback().
+ this.requestUpdate();
+ }
+
+ /**
+ * Component disconnected callback.
+ */
+ disconnectedCallback(): void {
+ // Destroy the carousel when the component is disconnected, which forces the
+ // plugins (which may have registered event handlers) to also be destroyed.
+ // The carousel will automatically reconstruct if the component is re-rendered.
+ this._destroyCarousel({ savePosition: true });
+ super.disconnectedCallback();
+ }
+
+ /**
+ * Destroy the carousel if certain properties change.
+ * @param changedProps The changed properties
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ const destroyProperties = [
+ 'direction',
+ 'carouselOptions',
+ 'carouselPlugins',
+ ] as const;
+ if (destroyProperties.some((prop) => changedProps.has(prop))) {
+ this._destroyCarousel({ savePosition: true });
+ }
+ }
/**
* Scroll to a particular slide.
* @param index Slide number.
*/
- carouselScrollTo(index: number): void {
- this._carousel?.scrollTo(index, this._getTransitionEffect() === 'none');
+ public carouselScrollTo(index: number): void {
+ this._carousel?.scrollTo(index, this.transitionEffect === 'none');
+ }
+
+ /**
+ * Scroll to the previous slide.
+ */
+ public carouselScrollPrevious(): void {
+ this._carousel?.scrollPrev(this.transitionEffect === 'none');
+ }
+
+ /**
+ * Scroll to the next slide.
+ */
+ public carouselScrollNext(): void {
+ this._carousel?.scrollNext(this.transitionEffect === 'none');
}
/**
* Get the selected slide.
- * @returns The slide index or undefined if the carousel is not loaded.
+ * @returns A CarouselSelect object (index & element).
*/
- carouselSelected(): number | undefined {
- return this._carousel?.selectedScrollSnap();
+ public getCarouselSelected(): CarouselSelect | null {
+ const index = this._carousel?.selectedScrollSnap();
+ const element =
+ index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null;
+ if (index !== undefined && element) {
+ return {
+ index: index,
+ element: element,
+ };
+ }
+ return null;
+ }
+
+ /**
+ * Get the carousel.
+ */
+ public carouselClickAllowed(): boolean {
+ return this._carousel?.clickAllowed() ?? true;
+ }
+
+ /**
+ * Get the carousel.
+ */
+ public carousel(): EmblaCarouselType | null {
+ return this._carousel ?? null;
+ }
+
+ /**
+ * ReInit the carousel.
+ */
+ protected _carouselReInit(options?: EmblaOptionsType): void {
+ // Allow the browser a moment to paint components that are inflight, to
+ // ensure accurate measurements are taken during the carousel
+ // reinitialization.
+ window.requestAnimationFrame(() => {
+ this._carousel?.reInit({ ...options });
+ });
+ }
+ /**
+ * ReInit the carousel but stay on the current slide.
+ */
+ protected _carouselReInitInPlaceInternal(): void {
+ const selected = this.getCarouselSelected();
+
+ this._carouselReInit({
+ ...(selected && { startIndex: selected.index }),
+ });
+ }
+
+ /**
+ * ReInit the carousel when it is safe to do so without disturbing the
+ * appearance (i.e. cutting off a scroll in progress).
+ */
+ public carouselReInitWhenSafe(): void {
+ if (this._scrolling) {
+ this._reInitOnSettle = true;
+ } else {
+ this._carouselReInitInPlace();
+ }
+ }
+
+ /**
+ * Get the live carousel plugins.
+ */
+ public getCarouselPlugins(): EmblaPluginsType | null {
+ return this._carousel?.plugins() ?? null;
}
/**
@@ -42,44 +202,21 @@ export class FrigateCardCarousel extends LitElement {
super.updated(changedProperties);
if (!this._carousel) {
- this.updateComplete.then(() => {
- // Re-check for the carousel to prevent a double init.
- if (!this._carousel) {
- this._initCarousel();
- }
- });
+ this._initCarousel();
}
}
/**
- * Get the transition effect to use.
- * @returns An TransitionEffect object.
+ * Destroy the carousel.
+ * @param options If `savePosition` is set the existing carousel position
+ * will be saved so it can be restored if the carousel is recreated.
*/
- protected _getTransitionEffect(): TransitionEffect | undefined {
- return 'slide';
- }
-
- /**
- * Get the Embla options to use.
- * @returns An EmblaOptionsType object or undefined for no options.
- */
- protected _getOptions(): EmblaOptionsType | undefined {
- return undefined;
- }
-
- /**
- * Get the Embla plugins to use.
- * @returns An EmblaOptionsType object or undefined for no options.
- */
- protected _getPlugins(): EmblaPluginType[] | undefined {
- return undefined;
- }
-
- protected _destroyCarousel(): void {
+ protected _destroyCarousel(options?: { savePosition: boolean }): void {
+ this._savedStartIndex =
+ (options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null;
if (this._carousel) {
this._carousel.destroy();
}
- this._plugins = {};
this._carousel = undefined;
}
@@ -91,26 +228,88 @@ export class FrigateCardCarousel extends LitElement {
'.embla__viewport',
) as HTMLElement;
- if (carouselNode) {
- const plugins = this._getPlugins() ?? [];
- this._plugins = plugins.reduce((acc, cur) => {
- acc[cur.name] = cur;
- return acc;
- }, {});
+ const nodes: EmblaNodesType = {
+ root: carouselNode,
+ // As the slides are slotted, need to explicitly pull them out and pass
+ // them to Embla.
+ slides: this._refSlot.value?.assignedElements({ flatten: true }) as HTMLElement[],
+ };
- this._carousel = EmblaCarousel(carouselNode, this._getOptions(), plugins);
+ if (carouselNode && nodes.slides) {
+ this._carousel = EmblaCarousel(
+ nodes,
+ {
+ axis: this.direction == 'horizontal' ? 'x' : 'y',
+ speed: 20,
+ ...this.carouselOptions,
+ ...(this._savedStartIndex && { startIndex: this._savedStartIndex }),
+ },
+ this.carouselPlugins,
+ );
this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init'));
this._carousel.on('select', () => {
- const selected = this.carouselSelected();
- if (selected !== undefined) {
- dispatchFrigateCardEvent(this, 'carousel:select', {
- index: selected,
- });
+ const selected = this.getCarouselSelected();
+ if (selected) {
+ dispatchFrigateCardEvent(this, 'carousel:select', selected);
+ }
+
+ // Make sure every select causes a refresh to allow for re-paint of the
+ // next/previous controls.
+ this.requestUpdate();
+ });
+
+ this._carousel.on('scroll', () => {
+ this._scrolling = true;
+ });
+ this._carousel.on('settle', () => {
+ // Reinitialize the carousel if a request to reinitialize was made
+ // during scrolling (instead the request is handled after the scrolling
+ // has settled).
+ this._scrolling = false;
+ if (this._reInitOnSettle) {
+ this._reInitOnSettle = false;
+ this._carouselReInitInPlace();
+ }
+ });
+ this._carousel.on('settle', () => {
+ const selected = this.getCarouselSelected();
+ if (selected) {
+ dispatchFrigateCardEvent(this, 'carousel:settle', selected);
}
});
}
}
+ /**
+ * Called when the slotted children in the carousel change.
+ */
+ protected _slotChanged(): void {
+ // Cannot just re-init, because the slide elements themselves may have
+ // changed, and only a carousel init can pass in new (slotted) children. If
+ // the slides themselves change, any position the user has set is assumed to
+ // be abandoned and so the startIndex is reset to whatever the carousel was
+ // originally configured with.
+ this._destroyCarousel({ savePosition: false });
+ this.requestUpdate();
+ }
+
+ protected render(): TemplateResult | void {
+ const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
+ const currentSlide = this._carousel?.selectedScrollSnap() ?? 0;
+ const showPrevious = this.carouselOptions?.loop || currentSlide > 0;
+ const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
+
+ return html`
+ ${showPrevious ? html`
` : ``}
+
+ ${showNext ? html`
` : ``}
+
`;
+ }
+
/**
* Get element styles.
*/
@@ -118,3 +317,9 @@ export class FrigateCardCarousel extends LitElement {
return unsafeCSS(carouselStyle);
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-carousel': FrigateCardCarousel;
+ }
+}
diff --git a/src/components/drawer.ts b/src/components/drawer.ts
new file mode 100644
index 00000000..dfff296a
--- /dev/null
+++ b/src/components/drawer.ts
@@ -0,0 +1,144 @@
+import {
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { createRef, ref, Ref } from 'lit/directives/ref.js';
+import 'side-drawer';
+import { SideDrawer } from 'side-drawer';
+import drawerInjectStyle from '../scss/drawer-inject.scss';
+import drawerStyle from '../scss/drawer.scss';
+import { stopEventFromActivatingCardWideActions } from '../utils/action';
+import { isHoverableDevice } from '../utils/basic';
+
+@customElement('frigate-card-drawer')
+export class FrigateCardDrawer extends LitElement {
+ @property({ attribute: true, reflect: true })
+ public location: 'left' | 'right' = 'left';
+
+ @property({ attribute: true, reflect: true, type: Boolean })
+ public control = true;
+
+ @property({ type: Boolean, reflect: true, attribute: true })
+ public open = false;
+
+ // The 'empty' attribute is used in the styling to change the drawer
+ // visibility and that of all descendants if there is no content. Styling is
+ // used rather than display or hidden in order to ensure the contents continue
+ // to have a measurable size.
+ @property({ type: Boolean, reflect: true, attribute: true })
+ public empty = true;
+
+ protected _refDrawer: Ref = createRef();
+ protected _refSlot: Ref = createRef();
+
+ protected _resizeObserver = new ResizeObserver(() => this._hideDrawerIfNecessary());
+
+ protected readonly _isHoverableDevice = isHoverableDevice();
+
+ /**
+ * Called on the first update.
+ * @param changedProps The changed properties.
+ */
+ protected firstUpdated(changedProps: PropertyValues): void {
+ super.firstUpdated(changedProps);
+
+ // The `side-drawer` component (and the material drawer for that matter)
+ // only do fixed drawers (i.e. a drawer for the whole viewport). Hackily
+ // override the style to customize the drawer to be absolute within the div.
+ const style = document.createElement('style');
+ style.innerHTML = drawerInjectStyle;
+ this._refDrawer.value?.shadowRoot?.appendChild(style);
+ }
+
+ /**
+ * Called when the slotted children in the drawer change.
+ */
+ protected _slotChanged(): void {
+ const elements = this._refSlot.value?.assignedElements({ flatten: true });
+
+ // Watch all slot children for size changes.
+ this._resizeObserver.disconnect();
+ for (const element of elements ?? []) {
+ this._resizeObserver.observe(element);
+ }
+ this._hideDrawerIfNecessary();
+ }
+
+ /**
+ * Hide the drawer if there is nothing to show.
+ * @returns
+ */
+ protected _hideDrawerIfNecessary(): void {
+ if (!this._refDrawer.value) {
+ return;
+ }
+
+ const elements = this._refSlot.value?.assignedElements({ flatten: true });
+ this.empty =
+ !elements ||
+ !elements.length ||
+ elements.every((element) => {
+ const box = element.getBoundingClientRect();
+ return !box.width || !box.height;
+ });
+ }
+
+ protected render(): TemplateResult {
+ return html`
+ {
+ if (this.open) {
+ this.open = false;
+ }
+ }}
+ >
+ ${this.control
+ ? html`
+ {
+ stopEventFromActivatingCardWideActions(ev);
+ this.open = !this.open;
+ }}
+ >
+ {
+ // Only open the drawer on mousenter when the device
+ // supports hover (otherwise iOS may end up passing on
+ // subsequent click events to a different element, see:
+ // https://github.com/dermotduffy/frigate-hass-card/issues/801
+ if (this._isHoverableDevice && !this.open) {
+ this.open = true;
+ }
+ }}
+ >
+
+
+ `
+ : ''}
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(drawerStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-drawer': FrigateCardDrawer;
+ 'side-drawer': SideDrawer;
+ }
+}
diff --git a/src/components/elements.ts b/src/components/elements.ts
index 9915a931..6061e181 100644
--- a/src/components/elements.ts
+++ b/src/components/elements.ts
@@ -1,24 +1,39 @@
-import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit';
-import { HomeAssistant } from 'custom-card-helpers';
-import { customElement, property, query } from 'lit/decorators.js';
-
+import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers';
import {
- ExtendedHomeAssistant,
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
+import { localize } from '../localize/localize.js';
+import elementsStyle from '../scss/elements.scss';
+import ptzStyle from '../scss/elements-ptz.scss';
+import {
+ Actions,
+ ActionsConfig,
+ FrigateCardError,
+ FrigateCardPTZConfig,
FrigateConditional,
MenuButton,
MenuIcon,
MenuStateIcon,
- PictureElements,
MenuSubmenu,
+ MenuSubmenuSelect,
+ PictureElements,
} from '../types.js';
+import { dispatchFrigateCardEvent } from '../utils/basic.js';
+import { dispatchFrigateCardErrorEvent } from './message.js';
+import { actionHandler } from '../action-handler-directive.js';
import {
- dispatchErrorMessageEvent,
- dispatchFrigateCardEvent,
-} from '../common.js';
-
-import elementsStyle from '../scss/elements.scss';
-import { localize } from '../localize/localize.js';
-import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
+ frigateCardHandleActionConfig,
+ frigateCardHasAction,
+ getActionConfigGivenAction,
+} from '../utils/action.js';
+import { classMap } from 'lit/directives/class-map.js';
/* A note on picture element rendering:
*
@@ -53,33 +68,29 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio
* upper layers to handle correctly.
*/
+interface HuiConditionalElement extends HTMLElement {
+ hass: HomeAssistant;
+ setConfig(config: unknown): void;
+}
+
// A small wrapper around a HA conditional element used to render a set of
// picture elements.
@customElement('frigate-card-elements-core')
-class FrigateCardElementsCore extends LitElement {
+export class FrigateCardElementsCore extends LitElement {
@property({ attribute: false })
- protected elements: PictureElements;
+ public elements: PictureElements;
/**
* Need to ensure card re-renders when conditionState changes, hence having it
* as a property even though it is not currently directly used by this class.
*/
@property({ attribute: false })
- protected conditionState?: ConditionState;
+ public conditionState?: ConditionState;
- protected _root: HTMLElement | null = null;
- protected _hass?: HomeAssistant & ExtendedHomeAssistant;
+ protected _root: HuiConditionalElement | null = null;
- /**
- * Set Home Assistant object.
- */
- set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
- if (this._root) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (this._root as any).hass = hass;
- }
- this._hass = hass;
- }
+ @property({ attribute: false })
+ public hass?: HomeAssistant;
/**
* Create a transparent render root.
@@ -90,17 +101,16 @@ class FrigateCardElementsCore extends LitElement {
/**
* Create the root node for our picture elements.
- * @returns
+ * @returns The newly created root.
*/
- protected _createRoot(): HTMLElement {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const elementConstructor = customElements.get('hui-conditional-element') as any;
- if (!elementConstructor || !this._hass) {
+ protected _createRoot(): HuiConditionalElement {
+ const elementConstructor = customElements.get('hui-conditional-element');
+ if (!elementConstructor || !this.hass) {
throw new Error(localize('error.could_not_render_elements'));
}
- const element = new elementConstructor();
- element.hass = this._hass;
+ const element = new elementConstructor() as HuiConditionalElement;
+ element.hass = this.hass;
const config = {
type: 'conditional',
conditions: [],
@@ -109,26 +119,43 @@ class FrigateCardElementsCore extends LitElement {
try {
element.setConfig(config);
} catch (e) {
- console.error(e, (e as Error).stack);
- throw new Error(localize('error.invalid_elements_config'));
+ console.error(e);
+ throw new FrigateCardError(localize('error.invalid_elements_config'));
}
return element;
}
+ /**
+ * Create the root as necessary prior to rendering.
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ try {
+ // The root is only created once per elements configuration change, to
+ // avoid the elements being continually re-created & destroyed (for some
+ // elements, e.g. image, recreation causes a flicker).
+ if (this.elements && (!this._root || changedProps.has('elements'))) {
+ this._root = this._createRoot();
+ }
+ } catch (e) {
+ return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
+ }
+ }
+
/**
* Render the elements.
* @returns A rendered template or void.
*/
protected render(): TemplateResult | void {
- try {
- // Recreate the root on each render to ensure conditional ancestors
- // re-fire events as necessary.
- this._root = this._createRoot();
- } catch (e) {
- return dispatchErrorMessageEvent(this, (e as Error).message);
- }
return html`${this._root || ''}`;
}
+
+ protected updated(): void {
+ if (this.hass && this._root) {
+ // Always update hass. It is used as a trigger to re-evaluate conditions
+ // down the chain, see the note on FrigateCardElementsConditional.
+ this._root.hass = this.hass;
+ }
+ }
}
/**
@@ -137,16 +164,15 @@ class FrigateCardElementsCore extends LitElement {
@customElement('frigate-card-elements')
export class FrigateCardElements extends LitElement {
@property({ attribute: false })
- public hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: HomeAssistant;
@property({ attribute: false })
- protected elements: PictureElements;
+ public conditionState?: ConditionState;
@property({ attribute: false })
- protected conditionState?: ConditionState;
+ public elements: PictureElements;
- @query('frigate-card-elements-core')
- _core!: FrigateCardElementsCore;
+ protected _boundMenuRemoveHandler = this._menuRemoveHandler.bind(this);
/**
* Handle a picture element to be removed from the menu.
@@ -178,13 +204,10 @@ export class FrigateCardElements extends LitElement {
// Ensure listener is only attached 1 time by removing it first.
path[0].removeEventListener(
'frigate-card:menu-remove',
- this._menuRemoveHandler.bind(this),
+ this._boundMenuRemoveHandler,
);
- path[0].addEventListener(
- 'frigate-card:menu-remove',
- this._menuRemoveHandler.bind(this),
- );
+ path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
}
/**
@@ -235,20 +258,13 @@ export class FrigateCardElements extends LitElement {
@customElement('frigate-card-conditional')
export class FrigateCardElementsConditional extends LitElement {
protected _config?: FrigateConditional;
- protected _hass?: HomeAssistant & ExtendedHomeAssistant;
- @query('frigate-card-elements-core')
- _core?: FrigateCardElementsCore;
-
- /**
- * Set the Home Assistant object.
- */
- set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
- if (this._core) {
- this._core.hass = hass;
- }
- this._hass = hass;
- }
+ // Every set of hass is treated as a reason to re-evaluate. Given that this
+ // node may be buried down the DOM (as a descendent of non-Frigate card
+ // elements), the hass object is used as the (only) trigger for condition
+ // re-fetch even if hass itself has not changed.
+ @property({ attribute: false, hasChanged: () => true })
+ public hass?: HomeAssistant;
/**
* Set the card configuration.
@@ -260,7 +276,7 @@ export class FrigateCardElementsConditional extends LitElement {
/**
* Create a root into which to render. This card is "transparent".
- * @returns
+ * @returns
*/
createRenderRoot(): LitElement {
return this;
@@ -284,7 +300,7 @@ export class FrigateCardElementsConditional extends LitElement {
protected render(): TemplateResult | void {
if (fetchStateAndEvaluateCondition(this, this._config.conditions)) {
return html`
`;
@@ -294,7 +310,7 @@ export class FrigateCardElementsConditional extends LitElement {
// A base class for rendering menu icons / menu state icons.
export class FrigateCardElementsBaseMenuIcon extends LitElement {
- @property({ attribute: false })
+ @state()
protected _config: T | null = null;
/**
@@ -333,4 +349,128 @@ export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon {}
@customElement('frigate-card-menu-submenu')
-export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon {}
\ No newline at end of file
+export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon {}
+
+@customElement('frigate-card-menu-submenu-select')
+export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon {}
+
+@customElement('frigate-card-ptz')
+export class FrigateCardPTZ extends LitElement {
+ @property({ attribute: false })
+ public hass?: HomeAssistant;
+
+ @state()
+ protected _config: FrigateCardPTZConfig | null = null;
+
+ /**
+ * Set the card config.
+ * @param config The configuration.
+ */
+ public setConfig(config: FrigateCardPTZConfig): void {
+ this._config = config;
+ }
+
+ /**
+ * Called before each update.
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('_config')) {
+ this.setAttribute('data-orientation', this._config?.orientation ?? 'vertical');
+ }
+ }
+
+ /**
+ * Handle a PTZ action.
+ * @param ev The actionHandler event.
+ * @param config The action configuration.
+ */
+ protected _actionHandler(
+ ev: HASSDomEvent<{ action: string }>,
+ config?: ActionsConfig,
+ ): void {
+ // Nothing else has the configuration for this action, so don't let it
+ // propagate further.
+ ev.stopPropagation();
+
+ const interaction: string = ev.detail.action;
+ const action = getActionConfigGivenAction(interaction, config);
+ if (config && action && this.hass) {
+ frigateCardHandleActionConfig(this, this.hass, config, interaction, action);
+ }
+ }
+
+ /**
+ * Render the elements.
+ * @returns A rendered template or void.
+ */
+ protected render(): TemplateResult | void {
+ if (!this._config) {
+ return;
+ }
+ const renderIcon = (
+ name: string,
+ icon: string,
+ actions?: Actions,
+ ): TemplateResult => {
+ const hasHold = frigateCardHasAction(actions?.hold_action);
+ const hasDoubleClick = frigateCardHasAction(actions?.double_tap_action);
+ const classes = {
+ [name]: true,
+ disabled: !actions,
+ };
+
+ return html` this._actionHandler(ev, actions)}
+ >`;
+ };
+
+ return html`
+
+ ${renderIcon('right', 'mdi:arrow-right', this._config.actions_right)}
+ ${renderIcon('left', 'mdi:arrow-left', this._config.actions_left)}
+ ${renderIcon('up', 'mdi:arrow-up', this._config.actions_up)}
+ ${renderIcon('down', 'mdi:arrow-down', this._config.actions_down)}
+
+ ${this._config.actions_zoom_in || this._config.actions_zoom_out
+ ? html`
+ ${renderIcon('zoom_in', 'mdi:plus', this._config.actions_zoom_in)}
+ ${renderIcon('zoom_out', 'mdi:minus', this._config.actions_zoom_out)}
+
`
+ : html``}
+ ${this._config.actions_home
+ ? html`
+
+ ${renderIcon('home', 'mdi:home', this._config.actions_home)}
+
+ `
+ : html``}
+
`;
+ }
+
+ /**
+ * Return compiled CSS styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(ptzStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-conditional': FrigateCardElementsConditional;
+ 'frigate-card-elements': FrigateCardElements;
+ 'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect;
+ 'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu;
+ 'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon;
+ 'frigate-card-menu-icon': FrigateCardElementsMenuIcon;
+ 'frigate-card-elements-core': FrigateCardElementsCore;
+ 'frigate-card-ptz': FrigateCardPTZ;
+ }
+}
diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts
index ce407027..078eac90 100644
--- a/src/components/embla-plugins/automedia.ts
+++ b/src/components/embla-plugins/automedia.ts
@@ -1,34 +1,65 @@
-import { EmblaCarouselType, EmblaPluginType } from 'embla-carousel';
-import { FrigateCardMediaPlayer } from '../../types.js';
+import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
+import { CreateOptionsType } from 'embla-carousel/components/Options.js';
+import { CreatePluginType } from 'embla-carousel/components/Plugins.js';
+import {
+ AutoMuteCondition,
+ AutoPauseCondition,
+ AutoPlayCondition,
+ AutoUnmuteCondition,
+ FrigateCardMediaPlayer,
+} from '../../types.js';
-export type AutoMediaPluginOptionsType = {
- playerSelector: string;
- autoPlayWhenVisible?: boolean;
- autoUnmuteWhenVisible?: boolean;
+type OptionsType = CreateOptionsType<{
+ playerSelector?: string;
+
+ // Note: Neither play nor unmute will activate on selection. The caller is
+ // expected to call the `play()` or `unmute()` methods manually when the media
+ // is actually loaded (and not just when the slide is visible -- the browser
+ // cannot play media that is not actually loaded yet, e.g. lazy loading).
+ autoPlayCondition?: AutoPlayCondition;
+ autoUnmuteCondition?: AutoUnmuteCondition;
+ autoPauseCondition?: AutoPauseCondition;
+ autoMuteCondition?: AutoMuteCondition;
+}>;
+
+const defaultOptions: OptionsType = {
+ active: true,
+ breakpoints: {},
};
-export const defaultOptions: Partial = {
- autoPlayWhenVisible: true,
- autoUnmuteWhenVisible: true,
-};
+export type AutoMediaOptionsType = Partial
-export type AutoMediaPluginType = EmblaPluginType & {
- play: () => void;
- pause: () => void;
- mute: () => void;
- unmute: () => void;
+export type AutoMediaType = CreatePluginType<
+ {
+ play: () => void;
+ pause: () => void;
+ mute: () => void;
+ unmute: () => void;
+ },
+ AutoMediaOptionsType
+>;
+
+declare module 'embla-carousel/components/Plugins' {
+ interface EmblaPluginsType {
+ autoMedia?: AutoMediaType
+ }
}
/**
* An Embla plugin to take automated actions on media (e.g. pause, unmute, etc).
- * @param userOptions
- * @returns
+ * @param userOptions
+ * @returns
*/
export function AutoMediaPlugin(
- userOptions?: AutoMediaPluginOptionsType,
-): AutoMediaPluginType {
- const options = Object.assign({}, defaultOptions, userOptions);
+ userOptions?: AutoMediaOptionsType,
+): AutoMediaType {
+ const optionsHandler = EmblaCarousel.optionsHandler();
+ const optionsBase = optionsHandler.merge(
+ defaultOptions,
+ AutoMediaPlugin.globalOptions,
+ );
+ let options: AutoMediaType['options'];
let carousel: EmblaCarouselType;
let slides: HTMLElement[];
@@ -37,15 +68,26 @@ export function AutoMediaPlugin(
*/
function init(embla: EmblaCarouselType): void {
carousel = embla;
+ options = optionsHandler.atMedia(self.options);
slides = carousel.slideNodes();
// Frigate card media autoplays when the media loads not necessarily when the
// slide is selected, so only pause (and not play/unmute) based on carousel
// events.
carousel.on('destroy', pause);
- carousel.on('select', pausePrevious);
+ if (
+ options.autoPauseCondition &&
+ ['all', 'unselected'].includes(options.autoPauseCondition)
+ ) {
+ carousel.on('select', pausePrevious);
+ }
carousel.on('destroy', mute);
- carousel.on('select', mutePrevious);
+ if (
+ options.autoMuteCondition &&
+ ['all', 'unselected'].includes(options.autoMuteCondition)
+ ) {
+ carousel.on('select', mutePrevious);
+ }
document.addEventListener('visibilitychange', visibilityHandler);
}
@@ -55,9 +97,19 @@ export function AutoMediaPlugin(
*/
function destroy(): void {
carousel.off('destroy', pause);
- carousel.off('select', pausePrevious);
+ if (
+ options.autoPauseCondition &&
+ ['all', 'unselected'].includes(options.autoPauseCondition)
+ ) {
+ carousel.off('select', pausePrevious);
+ }
carousel.off('destroy', mute);
- carousel.off('select', mutePrevious);
+ if (
+ options.autoMuteCondition &&
+ ['all', 'unselected'].includes(options.autoMuteCondition)
+ ) {
+ carousel.off('select', mutePrevious);
+ }
document.removeEventListener('visibilitychange', visibilityHandler);
}
@@ -65,15 +117,31 @@ export function AutoMediaPlugin(
/**
* Handle document visibility changes.
*/
- function visibilityHandler(): void {
- if (document.visibilityState == 'hidden') {
- pause();
- mute();
- } else if (document.visibilityState == 'visible') {
- if (options.autoPlayWhenVisible) {
+ function visibilityHandler(): void {
+ if (document.visibilityState === 'hidden') {
+ if (
+ options.autoPauseCondition &&
+ ['all', 'hidden'].includes(options.autoPauseCondition)
+ ) {
+ pauseAll();
+ }
+ if (
+ options.autoMuteCondition &&
+ ['all', 'hidden'].includes(options.autoMuteCondition)
+ ) {
+ muteAll();
+ }
+ } else if (document.visibilityState === 'visible') {
+ if (
+ options.autoPlayCondition &&
+ ['all', 'visible'].includes(options.autoPlayCondition)
+ ) {
play();
- }
- if (options.autoUnmuteWhenVisible) {
+ }
+ if (
+ options.autoUnmuteCondition &&
+ ['all', 'visible'].includes(options.autoUnmuteCondition)
+ ) {
unmute();
}
}
@@ -85,7 +153,9 @@ export function AutoMediaPlugin(
* @returns A FrigateCardMediaPlayer object or `null`.
*/
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
- return slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null;
+ return options.playerSelector
+ ? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
+ : null;
}
/**
@@ -109,6 +179,15 @@ export function AutoMediaPlugin(
getPlayer(slides[carousel.previousScrollSnap()])?.pause();
}
+ /**
+ * Pause all slides.
+ */
+ function pauseAll(): void {
+ for (const slide of slides) {
+ getPlayer(slide)?.pause();
+ }
+ }
+
/**
* Unmute the current slide.
*/
@@ -126,13 +205,22 @@ export function AutoMediaPlugin(
/**
* Mute the previous slide.
*/
- function mutePrevious(): void {
+ function mutePrevious(): void {
getPlayer(slides[carousel.previousScrollSnap()])?.mute();
}
- const self: AutoMediaPluginType = {
- name: 'AutoMediaPlugin',
- options,
+ /**
+ * Mute all slides.
+ */
+ function muteAll(): void {
+ for (const slide of slides) {
+ getPlayer(slide)?.mute();
+ }
+ }
+
+ const self: AutoMediaType = {
+ name: 'autoMedia',
+ options: optionsHandler.merge(optionsBase, userOptions),
init,
destroy,
play,
@@ -142,3 +230,5 @@ export function AutoMediaPlugin(
};
return self;
}
+
+AutoMediaPlugin.globalOptions = undefined;
diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts
index 40a74282..3fbdb9f4 100644
--- a/src/components/embla-plugins/lazyload.ts
+++ b/src/components/embla-plugins/lazyload.ts
@@ -1,28 +1,47 @@
-import { EmblaCarouselType, EmblaEventType, EmblaPluginType } from 'embla-carousel';
+import { CreateOptionsType } from 'embla-carousel/components/Options';
+import { CreatePluginType } from 'embla-carousel/components/Plugins';
+import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
+import { LazyUnloadCondition } from '../../types';
-export type LazyloadOptionsType = {
+export type OptionsType = CreateOptionsType<{
// Number of slides to lazyload left/right of selected (0 == only selected
// slide).
- lazyloadCount?: number;
+ lazyLoadCount?: number;
+ lazyUnloadCondition?: LazyUnloadCondition;
- lazyloadCallback?: (index: number, slide: HTMLElement) => void;
- lazyunloadCallback?: (index: number, slide: HTMLElement) => void;
+ lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
+ lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
+}>;
+
+export const defaultOptions: OptionsType = {
+ active: true,
+ breakpoints: {},
+ lazyLoadCount: 0,
};
-export const defaultOptions: Partial = {
- lazyloadCount: 0,
-};
+export type LazyloadOptionsType = Partial;
-export type LazyloadType = EmblaPluginType & {
- hasLazyloaded: (index: number) => boolean;
-};
+export type LazyloadType = CreatePluginType<
+ {
+ hasLazyloaded(index: number): boolean;
+ },
+ LazyloadOptionsType
+>;
+
+declare module 'embla-carousel/components/Plugins' {
+ interface EmblaPluginsType {
+ lazyload?: LazyloadType;
+ }
+}
export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
- const options = Object.assign({}, defaultOptions, userOptions);
+ const optionsHandler = EmblaCarousel.optionsHandler();
+ const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions);
+ let options: LazyloadType['options'];
let carousel: EmblaCarouselType;
let slides: HTMLElement[];
- const isSlideLazyloaded: Record = {};
+ const lazyLoadedSlides: Set = new Set();
const loadEvents: EmblaEventType[] = ['init', 'select', 'resize'];
const unloadEvents: EmblaEventType[] = ['select'];
@@ -32,13 +51,18 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
*/
function init(embla: EmblaCarouselType): void {
carousel = embla;
+ options = optionsHandler.atMedia(self.options);
slides = carousel.slideNodes();
- if (options.lazyloadCallback) {
- loadEvents.forEach((evt) => carousel.on(evt, lazyloadHandler));
+ if (options.lazyLoadCallback) {
+ loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler));
}
- if (options.lazyunloadCallback) {
- unloadEvents.forEach((evt) => carousel.on(evt, lazyunloadHandler));
+ if (
+ options.lazyUnloadCallback &&
+ options.lazyUnloadCondition &&
+ ['all', 'unselected'].includes(options.lazyUnloadCondition)
+ ) {
+ unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler));
}
document.addEventListener('visibilitychange', visibilityHandler);
}
@@ -47,11 +71,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
* Destroy the plugin.
*/
function destroy(): void {
- if (options.lazyloadCallback) {
- loadEvents.forEach((evt) => carousel.off(evt, lazyloadHandler));
+ if (options.lazyLoadCallback) {
+ loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler));
}
- if (options.lazyunloadCallback) {
- unloadEvents.forEach((evt) => carousel.off(evt, lazyunloadHandler));
+ if (options.lazyUnloadCallback) {
+ unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler));
}
document.removeEventListener('visibilitychange', visibilityHandler);
}
@@ -60,10 +84,15 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
* Handle document visibility changes.
*/
function visibilityHandler(): void {
- if (document.visibilityState == 'hidden' && lazyunloadHandler) {
- lazyunloadHandler();
- } else if (document.visibilityState == 'visible' && lazyloadHandler) {
- lazyloadHandler();
+ if (
+ document.visibilityState === 'hidden' &&
+ options.lazyUnloadCallback &&
+ options.lazyUnloadCondition &&
+ ['all', 'hidden'].includes(options.lazyUnloadCondition)
+ ) {
+ lazyUnloadAllHandler();
+ } else if (document.visibilityState === 'visible' && options.lazyLoadCallback) {
+ lazyLoadHandler();
}
}
@@ -73,14 +102,14 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
* @returns `true` if the slide has been lazily loaded.
*/
function hasLazyloaded(index: number): boolean {
- return !!isSlideLazyloaded[index];
+ return lazyLoadedSlides.has(index);
}
/**
* Lazily load media in the carousel.
*/
- function lazyloadHandler(): void {
- const lazyLoadCount = options.lazyloadCount ?? 0;
+ function lazyLoadHandler(): void {
+ const lazyLoadCount = options.lazyLoadCount ?? 0;
const currentIndex = carousel.selectedScrollSnap();
const slidesToLoad = new Set();
@@ -94,39 +123,45 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
}
slidesToLoad.forEach((index) => {
- // Only lazy load slides that are not already loaded.
- if (isSlideLazyloaded[index]) {
- return;
- }
- if (options.lazyloadCallback) {
- isSlideLazyloaded[index] = true;
- options.lazyloadCallback(index, slides[index]);
+ if (!hasLazyloaded(index) && options.lazyLoadCallback) {
+ lazyLoadedSlides.add(index);
+ options.lazyLoadCallback(index, slides[index]);
}
});
}
/**
- * Lazily unload media in the carousel.
+ * Lazily unload all media in the carousel.
*/
- function lazyunloadHandler(): void {
+ function lazyUnloadAllHandler(): void {
+ lazyLoadedSlides.forEach((index) => {
+ if (options.lazyUnloadCallback) {
+ options.lazyUnloadCallback(index, slides[index]);
+ lazyLoadedSlides.delete(index);
+ }
+ });
+ }
+
+ /**
+ * Lazily unload the previously selected media in the carousel.
+ */
+ function lazyUnloadPreviousHandler(): void {
const index = carousel.previousScrollSnap();
- // Only lazy unload slides that are lazy loaded.
- if (!isSlideLazyloaded[index]) {
- return;
- }
- if (options.lazyunloadCallback) {
- options.lazyunloadCallback(index, slides[index]);
- isSlideLazyloaded[index] = false;
+ if (hasLazyloaded(index) && options.lazyUnloadCallback) {
+ options.lazyUnloadCallback(index, slides[index]);
+ lazyLoadedSlides.delete(index);
}
}
const self: LazyloadType = {
- name: 'Lazyload',
- options,
+ name: 'lazyload',
+ options: optionsHandler.merge(optionsBase, userOptions),
init,
destroy,
hasLazyloaded,
};
return self;
}
+
+Lazyload.globalOptions = undefined;
diff --git a/src/components/gallery.ts b/src/components/gallery.ts
index 13fa510f..d0391272 100644
--- a/src/components/gallery.ts
+++ b/src/components/gallery.ts
@@ -1,59 +1,78 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
-import { HomeAssistant } from 'custom-card-helpers';
-import { customElement, property, state } from 'lit/decorators.js';
-import { styleMap } from 'lit/directives/style-map.js';
-
+import {
+ css,
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import galleryStyle from '../scss/gallery.scss';
import {
CameraConfig,
ExtendedHomeAssistant,
- GalleryConfig,
frigateCardConfigDefaults,
+ GalleryConfig,
+ THUMBNAIL_WIDTH_MAX,
} from '../types.js';
-import { BrowseMediaUtil } from '../browse-media-util.js';
+import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
+import {
+ fetchChildMediaAndDispatchViewChange,
+ fetchLatestMediaAndDispatchViewChange,
+ getFullDependentBrowseMediaQueryParametersOrDispatchError,
+} from '../utils/ha/browse-media';
import { View } from '../view.js';
import { renderProgressIndicator } from './message.js';
-import { stopEventFromActivatingCardWideActions } from '../common.js';
-
-import galleryStyle from '../scss/gallery.scss';
-
-const MAX_THUMBNAIL_WIDTH = 175;
+import './thumbnail.js';
+import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
@customElement('frigate-card-gallery')
export class FrigateCardGallery extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- protected view?: Readonly;
+ public view?: Readonly;
@property({ attribute: false })
- protected cameraConfig?: CameraConfig;
+ public galleryConfig?: GalleryConfig;
@property({ attribute: false })
- protected galleryConfig?: GalleryConfig;
+ public cameras?: Map;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
- if (!this.hass || !this.view || !this.cameraConfig) {
+ const mediaType = this.view?.getMediaType();
+ if (
+ !this.hass ||
+ !this.view ||
+ !this.cameras ||
+ !this.view.isGalleryView() ||
+ !mediaType
+ ) {
return;
}
if (!this.view.target) {
const browseMediaQueryParameters =
- BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
+ getFullDependentBrowseMediaQueryParametersOrDispatchError(
this,
- this.view,
- this.cameraConfig,
+ this.hass,
+ this.cameras,
+ this.view.camera,
+ mediaType,
);
+
if (!browseMediaQueryParameters) {
return;
}
- BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
+ fetchLatestMediaAndDispatchViewChange(
this,
this.hass,
this.view,
@@ -67,6 +86,7 @@ export class FrigateCardGallery extends LitElement {
.hass=${this.hass}
.view=${this.view}
.galleryConfig=${this.galleryConfig}
+ .cameras=${this.cameras}
>
`;
@@ -76,26 +96,32 @@ export class FrigateCardGallery extends LitElement {
* Get element styles.
*/
static get styles(): CSSResultGroup {
- return unsafeCSS(galleryStyle);
+ return css`
+ :host {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+ `;
}
}
@customElement('frigate-card-gallery-core')
export class FrigateCardGalleryCore extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- protected view?: Readonly;
+ public view?: Readonly;
@property({ attribute: false })
- protected galleryConfig?: GalleryConfig;
+ public galleryConfig?: GalleryConfig;
+
+ @property({ attribute: false })
+ public cameras?: Map;
protected _resizeObserver: ResizeObserver;
- @state()
- protected _columns = frigateCardConfigDefaults.event_gallery.min_columns;
-
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
@@ -117,17 +143,64 @@ export class FrigateCardGalleryCore extends LitElement {
super.disconnectedCallback();
}
+ /**
+ * Set gallery columns.
+ */
+ protected _setColumnCount(): void {
+ const thumbnailSize =
+ this.galleryConfig?.controls.thumbnails.size ??
+ frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
+ const columns = this.galleryConfig?.controls.thumbnails.show_details
+ ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
+ : Math.max(
+ 1,
+ Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX),
+ Math.ceil(this.clientWidth / thumbnailSize),
+ );
+
+ this.style.setProperty('--frigate-card-gallery-columns', String(columns));
+ }
+
/**
* Handle gallery resize.
*/
protected _resizeHandler(): void {
- this._columns = Math.max(
- this.galleryConfig?.min_columns ??
- frigateCardConfigDefaults.event_gallery.min_columns,
- Math.ceil(this.clientWidth / MAX_THUMBNAIL_WIDTH),
+ this._setColumnCount();
+ }
+
+ /**
+ * Determine whether the back arrow should be displayed.
+ * @returns `true` if the back arrow should be displayed, `false` otherwise.
+ */
+ protected _showBackArrow(): boolean {
+ return (
+ !!this.view?.previous &&
+ !!this.view.previous.target &&
+ this.view.previous.view === this.view.view
);
}
+ /**
+ * Called when an update will occur.
+ * @param changedProps The changed properties
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('galleryConfig')) {
+ if (this.galleryConfig?.controls.thumbnails.show_details) {
+ this.setAttribute('details', '');
+ } else {
+ this.removeAttribute('details');
+ }
+ this._setColumnCount();
+ if (this.galleryConfig?.controls.thumbnails.size) {
+ this.style.setProperty(
+ '--frigate-card-thumbnail-size',
+ `${this.galleryConfig.controls.thumbnails.size}px`,
+ );
+ }
+ }
+ }
+
/**
* Master render method.
* @returns A rendered template.
@@ -138,93 +211,78 @@ export class FrigateCardGalleryCore extends LitElement {
!this.view ||
!this.view.target ||
!this.view.target.children ||
- !(this.view.is('clips') || this.view.is('snapshots'))
+ !(this.view.is('clips') || this.view.is('snapshots')) ||
+ !this.cameras
) {
return html``;
}
- const itemStyle = {
- // Controls the number of columns in the gallery (allows for 5px gutter).
- width: `calc(${100 / this._columns}% - 5.25px)`,
- };
-
- const folderStyle = {
- // Values derived from experimentation on typical Lovelace card sizes.
- 'font-size': `${Math.min(
- 1.1,
- (0.6 * (this.clientWidth / this._columns)) / 50.0,
- )}em`,
- };
-
- return html`
- ${this.view && this.view.previous
- ? html`-
-
-
- {
- if (this.view && this.view.previous) {
- this.view.previous.dispatchChangeEvent(this);
- }
- stopEventFromActivatingCardWideActions(ev);
- }}
- outlined=""
- class="frigate-card-gallery-folder"
- >
-
-
-
-
- `
+ const cameraConfig = this.cameras.get(this.view.camera);
+ return html`
+ ${this._showBackArrow()
+ ? html` {
+ if (this.view && this.view.previous) {
+ this.view.previous.dispatchChangeEvent(this);
+ }
+ stopEventFromActivatingCardWideActions(ev);
+ }}
+ outlined=""
+ >
+
+ `
: ''}
${this.view.target.children.map(
(child, index) =>
- html` -
-
- ${child.can_expand
- ? html`
-
{
- if (this.hass && this.view) {
- BrowseMediaUtil.fetchChildMediaAndDispatchViewChange(
- this,
- this.hass,
- this.view,
- child,
- );
- }
- stopEventFromActivatingCardWideActions(ev);
- }}
- outlined=""
- class="frigate-card-gallery-folder"
- >
- ${child.title}
-
-
`
- : child.thumbnail
- ? html`

{
- if (this.view) {
- this.view
- .evolve({
- view: this.view.is('clips') ? 'clip' : 'snapshot',
- childIndex: index,
- previous: this.view,
- })
- .dispatchChangeEvent(this);
+ if (this.hass && this.view) {
+ fetchChildMediaAndDispatchViewChange(
+ this,
+ this.hass,
+ this.view,
+ child,
+ );
}
stopEventFromActivatingCardWideActions(ev);
}}
- />`
- : ``}
-
- `,
+ outlined=""
+ >
+ ${child.title}
+
+ `
+ : child.thumbnail
+ ? html` {
+ if (this.view) {
+ this.view
+ .evolve({
+ view: this.view.is('clips') ? 'clip' : 'snapshot',
+ childIndex: index,
+ })
+ .dispatchChangeEvent(this);
+ }
+ stopEventFromActivatingCardWideActions(ev);
+ }}
+ >
+ `
+ : ``}
+ `,
)}
-
`;
+ `;
}
/**
@@ -234,3 +292,10 @@ export class FrigateCardGalleryCore extends LitElement {
return unsafeCSS(galleryStyle);
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-gallery-core': FrigateCardGalleryCore;
+ 'frigate-card-gallery': FrigateCardGallery;
+ }
+}
diff --git a/src/components/image.ts b/src/components/image.ts
index 621a0aa9..a8f5a743 100644
--- a/src/components/image.ts
+++ b/src/components/image.ts
@@ -1,29 +1,29 @@
+import { HomeAssistant } from 'custom-card-helpers';
+import { HassEntity } from 'home-assistant-js-websocket';
import {
CSSResultGroup,
+ html,
LitElement,
PropertyValues,
TemplateResult,
- html,
- unsafeCSS,
+ unsafeCSS
} from 'lit';
-import { HomeAssistant } from 'custom-card-helpers';
-import { customElement, property, query, state } from 'lit/decorators.js';
-
+import { customElement, property } from 'lit/decorators.js';
+import { live } from 'lit/directives/live.js';
+import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CachedValueController } from '../cached-value-controller.js';
-import { CameraConfig, ImageViewConfig } from '../types.js';
-import { View } from '../view.js';
-import {
- dispatchErrorMessageEvent,
- dispatchMediaShowEvent,
- shouldUpdateBasedOnHass,
-} from '../common.js';
-import { localize } from '../localize/localize.js';
-
import defaultImage from '../images/frigate-bird-in-sky.jpg';
-
+import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss';
+import { CameraConfig, ImageViewConfig } from '../types.js';
+import { isHassDifferent } from '../utils/ha';
+import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
+import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
+import { View } from '../view.js';
+import { dispatchErrorMessageEvent } from './message.js';
+import { contentsChanged } from '../utils/basic.js';
-// See: https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py#L101
+// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@customElement('frigate-card-image')
@@ -32,40 +32,31 @@ export class FrigateCardImage extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false })
- protected view?: Readonly;
+ public view?: Readonly;
@property({ attribute: false })
- protected cameraConfig?: CameraConfig;
+ public cameraConfig?: CameraConfig;
- @state()
- protected _imageConfig?: ImageViewConfig;
+ // Using contentsChanged to ensure overridden configs (e.g. when the
+ // 'show_image_during_load' option is true for live views, an overridden
+ // config may be used here).
+ @property({ attribute: false, hasChanged: contentsChanged })
+ public imageConfig?: ImageViewConfig;
- @query('img')
- protected _image?: HTMLImageElement;
+ protected _refImage: Ref = createRef();
protected _cachedValueController?: CachedValueController;
protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
- /**
- * Set the image configuration.
- */
- set imageConfig(imageConfig: ImageViewConfig) {
- this._imageConfig = imageConfig;
- if (this._cachedValueController) {
- this._cachedValueController.removeController();
- }
- this._cachedValueController = new CachedValueController(
- this,
- this._imageConfig.refresh_seconds,
- this._getImageSource.bind(this),
- );
- }
/**
* Get the camera entity for the current camera configuration.
* @returns The entity or undefined if no camera entity is available.
*/
- protected _getCameraEntity(): string | undefined {
- return this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity;
+ protected _getCameraEntity(): string | null {
+ return (
+ (this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity) ??
+ null
+ );
}
/**
@@ -78,30 +69,14 @@ export class FrigateCardImage extends LitElement {
return false;
}
- // If camera mode is enabled, reject all updates if hass is older than
- // HASS_REJECTION_CUTOFF_MS or if HASS is not currently connected. By using
- // an older hass (even if it is not the property being updated), we run the
- // risk that the JS has an old access token for the camera, and that results
- // in a notification on the HA UI about a failed login. See
- // https://github.com/dermotduffy/frigate-hass-card/issues/398 .
const cameraEntity = this._getCameraEntity();
- const state = cameraEntity ? this.hass.states[cameraEntity] : undefined;
- if (
- this._imageConfig?.mode === 'camera' &&
- (!this.hass.connected ||
- !state ||
- Date.now() - Date.parse(state.last_updated) >= HASS_REJECTION_CUTOFF_MS)
- ) {
- return false;
- }
-
if (
changedProps.has('hass') &&
changedProps.size == 1 &&
- this._imageConfig?.mode === 'camera' &&
+ this.imageConfig?.mode === 'camera' &&
cameraEntity
) {
- if (shouldUpdateBasedOnHass(this.hass, changedProps.get('hass'), [cameraEntity])) {
+ if (isHassDifferent(this.hass, changedProps.get('hass'), [cameraEntity])) {
// If the state of the camera entity has changed, remove the cached
// value (will be re-calculated in willUpdate). This is important to
// ensure a changed access token is immediately used.
@@ -117,25 +92,73 @@ export class FrigateCardImage extends LitElement {
* Ensure there is a cached value before an update.
* @param _changedProps The changed properties
*/
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- protected willUpdate(_changedProps: PropertyValues): void {
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('imageConfig')) {
+ if (this._cachedValueController) {
+ this._cachedValueController.removeController();
+ }
+ if (this.imageConfig) {
+ this._cachedValueController = new CachedValueController(
+ this,
+ this.imageConfig.refresh_seconds,
+ this._getImageSource.bind(this),
+ );
+ }
+ updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout);
+ }
+
+ // If the camera or view changed, immediately discard the old value (view to
+ // allow pressing of the image button to fetch a fresh image). Likewise, if
+ // the state is not acceptable, discard the old value (to allow a stock or
+ // backup image to be displayed).
+ if (
+ changedProps.has('cameraConfig') ||
+ changedProps.has('view') ||
+ (this.imageConfig?.mode === 'camera' &&
+ !this._getAcceptableState(this._getCameraEntity()))
+ ) {
+ this._cachedValueController?.clearValue();
+ }
+
if (!this._cachedValueController?.value) {
this._cachedValueController?.updateValue();
}
}
+ /**
+ * Determine if a given entity is acceptable as the basis for an image render
+ * (detects old or disconnected states). Using an old state is problematic as
+ * it runs the risk that the JS has an old access token for the camera, and
+ * that results in a notification on the HA UI about a failed login. See:
+ * https://github.com/dermotduffy/frigate-hass-card/issues/398 .
+ * @param entity The entity.
+ * @returns The state or null if not acceptable.
+ */
+ protected _getAcceptableState(entity: string | null): HassEntity | null {
+ const state = (entity ? this.hass?.states[entity] : null) ?? null;
+
+ return !!this.hass &&
+ this.hass.connected &&
+ !!state &&
+ Date.now() - Date.parse(state.last_updated) < HASS_REJECTION_CUTOFF_MS
+ ? state
+ : null;
+ }
+
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
document.addEventListener('visibilitychange', this._boundVisibilityHandler);
+ this._cachedValueController?.startTimer();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
+ this._cachedValueController?.stopTimer();
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
super.disconnectedCallback();
}
@@ -144,7 +167,7 @@ export class FrigateCardImage extends LitElement {
* Handle document visibility changes.
*/
protected _visibilityHandler(): void {
- if (!this._image) {
+ if (!this._refImage.value) {
return;
}
if (document.visibilityState === 'hidden') {
@@ -155,13 +178,15 @@ export class FrigateCardImage extends LitElement {
// re-generation of a new URL would generate an unauthorized request
// (401), see:
// https://github.com/dermotduffy/frigate-hass-card/issues/398
+ this._cachedValueController?.stopTimer();
this._cachedValueController?.clearValue();
- this._forceStockImage();
+ this._forceSafeImage();
} else {
// If the document is freshly re-visible, immediately re-render it to
// restore the image src. If the HASS object is old (i.e. browser tab was
// inactive for some time) this update request may be (correctly)
// rejected.
+ this._cachedValueController?.startTimer();
this.requestUpdate();
}
}
@@ -178,49 +203,53 @@ export class FrigateCardImage extends LitElement {
}
protected _getImageSource(): string {
- if (this._imageConfig?.mode === 'url' && this._imageConfig?.url) {
- return this._buildImageURL(this._imageConfig.url);
- } else if (this.hass && this._imageConfig?.mode === 'camera') {
- const entity = this._getCameraEntity();
- if (entity) {
- const state = this.hass.states[entity];
- if (state && state.attributes.entity_picture) {
- return this._buildImageURL(state.attributes.entity_picture);
- }
+ if (this.hass && this.imageConfig?.mode === 'camera') {
+ const state = this._getAcceptableState(this._getCameraEntity());
+ if (state?.attributes.entity_picture) {
+ return this._buildImageURL(state.attributes.entity_picture);
}
}
+ if (this.imageConfig?.mode !== 'screensaver' && this.imageConfig?.url) {
+ return this._buildImageURL(this.imageConfig.url);
+ }
return defaultImage;
}
/**
- * Force the img element to the stock image.
+ * Force the img element to a safe image.
*/
- protected _forceStockImage(): void {
- if (this._image) {
- this._image.src = defaultImage;
+ protected _forceSafeImage(stockOnly?: boolean): void {
+ if (this._refImage.value) {
+ this._refImage.value.src =
+ !stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage;
}
}
protected render(): TemplateResult | void {
const src = this._cachedValueController?.value;
+ // Note the use of live() below to ensure the update will restore the image
+ // src if it's been changed via _forceSafeImage().
return src
? html`
{
- dispatchMediaShowEvent(this, ev);
+ ${ref(this._refImage)}
+ src=${live(src)}
+ @load=${(ev: Event) => {
+ dispatchMediaLoadedEvent(this, ev);
}}
@error=${() => {
- if (this._imageConfig?.mode === 'camera') {
+ if (this.imageConfig?.mode === 'camera') {
// In camera mode, the user has likely not made an error, but HA
- // may be unavailble, so show the stock image.
- this._forceStockImage();
- } else if (this._imageConfig?.mode === 'url') {
+ // may be unavailble, so show the stock image. Don't let the URL
+ // override the stock image in this case, as this could create an
+ // error loop if that URL subsequently failed to load.
+ this._forceSafeImage(true);
+ } else if (this.imageConfig?.mode === 'url') {
// In url mode, the user likely specified a URL that cannot be
// resolved. Show an error message.
dispatchErrorMessageEvent(
this,
localize('error.image_load_error'),
- this._imageConfig,
+ { context: this.imageConfig },
);
}
}}
@@ -232,3 +261,9 @@ export class FrigateCardImage extends LitElement {
return unsafeCSS(imageStyle);
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "frigate-card-image": FrigateCardImage
+ }
+}
diff --git a/src/components/live.ts b/src/components/live.ts
index cb54870a..9b67c980 100644
--- a/src/components/live.ts
+++ b/src/components/live.ts
@@ -1,66 +1,73 @@
+import JSMpeg from '@cycjimmy/jsmpeg-player';
+import { Task } from '@lit-labs/task';
+import { HomeAssistant } from 'custom-card-helpers';
+import { EmblaOptionsType } from 'embla-carousel';
+import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import {
CSSResultGroup,
- LitElement,
- TemplateResult,
html,
- unsafeCSS,
+ LitElement,
PropertyValues,
+ TemplateResult,
+ unsafeCSS,
} from 'lit';
-import {
- BrowseMediaSource,
- ExtendedHomeAssistant,
- CameraConfig,
- JSMPEGConfig,
- LiveConfig,
- MediaShowInfo,
- WebRTCCardConfig,
- FrigateCardError,
- FrigateCardMediaPlayer,
- LiveOverrides,
- LiveProvider,
- TransitionEffect,
- frigateCardConfigDefaults,
-} from '../types.js';
-import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
-import { HomeAssistant } from 'custom-card-helpers';
-import JSMpeg from '@cycjimmy/jsmpeg-player';
-import { Ref, createRef, ref } from 'lit/directives/ref.js';
-import { Task } from '@lit-labs/task';
-import { customElement, property, query, state } from 'lit/decorators.js';
+import { customElement, property, state } from 'lit/decorators.js';
+import { createRef, Ref, ref } from 'lit/directives/ref.js';
+import { guard } from 'lit/directives/guard.js';
+import { keyed } from 'lit/directives/keyed.js';
import { until } from 'lit/directives/until.js';
-import { styleMap } from 'lit/directives/style-map.js';
-
-import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js';
-import { BrowseMediaUtil } from '../browse-media-util.js';
import { ConditionState, getOverriddenConfig } from '../card-condition.js';
-import { FrigateCardMediaCarousel } from './media-carousel.js';
-import { FrigateCardNextPreviousControl } from './next-prev-control.js';
-import {
- FrigateCardThumbnailCarousel,
- ThumbnailCarouselTap,
-} from './thumbnail-carousel.js';
-import { Lazyload } from './embla-plugins/lazyload.js';
-import { View } from '../view.js';
+import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
import { localize } from '../localize/localize.js';
-import {
- contentsChanged,
- dispatchErrorMessageEvent,
- dispatchExistingMediaShowInfoAsEvent,
- dispatchMediaShowEvent,
- getCameraIcon,
- getCameraTitle,
- homeAssistantSignPath,
- stopEventFromActivatingCardWideActions,
-} from '../common.js';
-import { renderProgressIndicator } from '../components/message.js';
-
-import './next-prev-control.js';
-import './title-control.js';
-
-import liveStyle from '../scss/live.scss';
import liveFrigateStyle from '../scss/live-frigate.scss';
import liveJSMPEGStyle from '../scss/live-jsmpeg.scss';
import liveWebRTCStyle from '../scss/live-webrtc.scss';
+import liveStyle from '../scss/live.scss';
+import liveCarouselStyle from '../scss/live-carousel.scss';
+import liveProviderStyle from '../scss/live-provider.scss';
+import {
+ CameraConfig,
+ ExtendedHomeAssistant,
+ frigateCardConfigDefaults,
+ FrigateCardError,
+ FrigateCardMediaPlayer,
+ JSMPEGConfig,
+ LiveConfig,
+ LiveOverrides,
+ LiveProvider,
+ MediaLoadedInfo,
+ Message,
+ TransitionEffect,
+ WebRTCCardConfig,
+} from '../types.js';
+import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
+import { contentsChanged, errorToConsole } from '../utils/basic.js';
+import { getCameraIcon, getCameraTitle } from '../utils/camera.js';
+import { homeAssistantSignPath } from '../utils/ha';
+import { getFullDependentBrowseMediaQueryParameters } from '../utils/ha/browse-media.js';
+import {
+ dispatchExistingMediaLoadedInfoAsEvent,
+ dispatchMediaLoadedEvent,
+ dispatchMediaUnloadedEvent,
+} from '../utils/media-info.js';
+import { dispatchViewContextChangeEvent, View } from '../view.js';
+import { AutoMediaPlugin } from './embla-plugins/automedia.js';
+import { Lazyload } from './embla-plugins/lazyload.js';
+import {
+ FrigateCardMediaCarousel,
+ wrapMediaLoadedEventForCarousel,
+ wrapMediaUnloadedEventForCarousel,
+} from './media-carousel.js';
+import { dispatchErrorMessageEvent } from './message.js';
+import './next-prev-control.js';
+import './title-control.js';
+import './surround-thumbnails';
+import '../patches/ha-camera-stream';
+import { EmblaCarouselPlugins } from './carousel.js';
+import { renderTask } from '../utils/task.js';
+import { classMap } from 'lit/directives/class-map.js';
+import './image';
+import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
// Number of seconds a signed URL is valid for.
const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@@ -71,120 +78,97 @@ const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60;
@customElement('frigate-card-live')
export class FrigateCardLive extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public conditionState?: ConditionState;
@property({ attribute: false })
- protected view?: Readonly;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- protected cameras?: Map;
+ public view?: Readonly;
@property({ attribute: false })
- protected liveConfig?: LiveConfig;
+ public cameras?: Map;
@property({ attribute: false })
- protected liveOverrides?: LiveOverrides;
+ public liveConfig?: LiveConfig;
- @property({ attribute: false })
- protected conditionState?: ConditionState;
+ @property({ attribute: false, hasChanged: contentsChanged })
+ public liveOverrides?: LiveOverrides;
- set preloaded(preloaded: boolean) {
- this._preloaded = preloaded;
-
- if (!preloaded && this._savedMediaShowInfo) {
- dispatchExistingMediaShowInfoAsEvent(this, this._savedMediaShowInfo);
- }
- }
-
- // Whether or not the live view is currently being preloaded.
+ // Whether or not the live view is currently in the background (i.e. preloaded
+ // but not visible)
@state()
- protected _preloaded?: boolean;
+ protected _inBackground?: boolean = true;
- // MediaShowInfo object from the underlying live object. In the case of
- // pre-loading it may be propagated upwards later.
- protected _savedMediaShowInfo?: MediaShowInfo;
+ // Intersection handler is used to detect when the live view flips between
+ // foreground and background (in preload mode).
+ protected _intersectionObserver: IntersectionObserver;
- @query('frigate-card-thumbnail-carousel')
- protected _thumbnailCarousel?: FrigateCardThumbnailCarousel;
+ // MediaLoadedInfo object and message from the underlying live object. In the
+ // case of pre-loading these may be propagated upwards later.
+ protected _backgroundMediaLoadedInfo: MediaLoadedInfo | null = null;
+ protected _messageReceivedPostRender = false;
+ protected _renderKey = 0;
+
+ constructor() {
+ super();
+ this._intersectionObserver = new IntersectionObserver(
+ this._intersectionHandler.bind(this),
+ );
+ }
/**
- * Handler for media show events that special cases preloaded live views.
- * @param e The media show event.
+ * Called when the live view intersects with the viewport.
+ * @param entries The IntersectionObserverEntry entries (should be only 1).
*/
- protected _mediaShowHandler(e: CustomEvent): void {
- this._savedMediaShowInfo = e.detail;
- if (this._preloaded) {
- // If live is being pre-loaded, don't let the event propagate upwards yet
- // as the media is not really being shown.
- e.stopPropagation();
+ protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
+ this._inBackground = entries.every((entry) => !entry.isIntersecting);
+
+ if (
+ !this._inBackground &&
+ !this._messageReceivedPostRender &&
+ this._backgroundMediaLoadedInfo
+ ) {
+ // If this isn't being rendered in the background, the last render did not
+ // generate a message and there's a saved MediaInfo, dispatch it upwards.
+ dispatchExistingMediaLoadedInfoAsEvent(this, this._backgroundMediaLoadedInfo);
+ this._backgroundMediaLoadedInfo = null;
+ }
+
+ // Trigger a re-render which may be necessary if the prior render resulted
+ // in a message.
+ if (this._messageReceivedPostRender && !this._inBackground) {
+ this.requestUpdate();
}
}
/**
- * Render thumbnails carousel.
- * @returns A rendered template or void.
+ * Determine whether the element should be updated.
+ * @param _changedProps The changed properties if any.
+ * @returns `true` if the element should be updated.
*/
- protected renderThumbnails(config: LiveConfig): TemplateResult | void {
- if (!this.liveConfig || !this.view) {
- return;
- }
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ protected shouldUpdate(_changedProps: PropertyValues): 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._messageReceivedPostRender;
+ }
- const fetchThumbnailsThenRender = async (): Promise => {
- if (!this.hass || !this.cameras || !this.view) {
- return;
- }
- const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters(
- config.controls.thumbnails.media,
- this.cameras.get(this.view.camera),
- );
- if (!browseMediaParams) {
- return;
- }
- let parent: BrowseMediaSource | null;
- try {
- parent = await BrowseMediaUtil.browseMediaQuery(this.hass, browseMediaParams);
- } catch (e) {
- return dispatchErrorMessageEvent(this, (e as Error).message);
- }
+ /**
+ * Component connected callback.
+ */
+ connectedCallback(): void {
+ this._intersectionObserver.observe(this);
+ super.connectedCallback();
+ }
- if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
- return html`) => {
- const mediaType = browseMediaParams.mediaType;
- if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) {
- new View({
- view: mediaType === 'clips' ? 'clip' : 'snapshot',
- camera: this.view.camera,
- target: ev.detail.target,
- childIndex: ev.detail.childIndex,
- }).dispatchChangeEvent(this);
- }
- }}
- >
- `;
- }
- };
-
- const fillerStyle = {
- height: config.controls.thumbnails.size,
- };
-
- // As the live carousel moves, thumbnails are re-fetched. This is an async
- // request, so it can jarring to the user to have the main camera view nudge
- // up/down as the thumbnails disappear and re-appear. Instead, if there was
- // previously a thumbnail carousel rendered, use a filler that is the same
- // size until it is replaced with a real carousel (or empty, if no carousel
- // is rendered for the next camera).
- return html`${until(
- fetchThumbnailsThenRender(),
- this._thumbnailCarousel
- ? html` `
- : html``,
- )}`;
+ /**
+ * Component disconnected callback.
+ */
+ disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this._intersectionObserver.disconnect();
}
/**
@@ -192,7 +176,7 @@ export class FrigateCardLive extends LitElement {
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
- if (!this.hass || !this.liveConfig || !this.cameras) {
+ if (!this.hass || !this.liveConfig || !this.cameras || !this.view) {
return;
}
@@ -202,34 +186,70 @@ export class FrigateCardLive extends LitElement {
this.conditionState,
) as LiveConfig;
- // Note use of liveConfig and not config below -- the carousel will
- // independently override the liveconfig to reflect the camera in the
- // carousel (not necessarily the selected camera).
- return html`
- ${config.controls.thumbnails.mode === 'above' ? this.renderThumbnails(config) : ''}
- {
- if (this._preloaded) {
- // Don't allow change-view events to propagate upwards if the card
- // is only preloaded rather than being live displayed. These events
- // could be triggered if the camera is switched and the carousel
- // moves to focus on that camera -- as the card isn't actually being
- // displayed, do not allow the view to actually be updated.
+ ?fetch=${!this._inBackground}
+ @frigate-card:message=${(ev: CustomEvent) => {
+ this._renderKey++;
+ this._messageReceivedPostRender = true;
+ if (this._inBackground) {
+ ev.stopPropagation();
+ }
+ }}
+ @frigate-card:media:loaded=${(ev: CustomEvent) => {
+ if (this._inBackground) {
+ this._backgroundMediaLoadedInfo = ev.detail;
+ ev.stopPropagation();
+ }
+ }}
+ @frigate-card:view:change=${(ev: CustomEvent) => {
+ if (this._inBackground) {
ev.stopPropagation();
}
}}
>
-
- ${config.controls.thumbnails.mode === 'below' ? this.renderThumbnails(config) : ''}
- `;
+
+
+ `,
+ )}`;
+
+ this._messageReceivedPostRender = false;
+ return result;
}
/**
@@ -241,76 +261,72 @@ export class FrigateCardLive extends LitElement {
}
@customElement('frigate-card-live-carousel')
-export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
+export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- protected view?: Readonly;
+ public view?: Readonly;
@property({ attribute: false })
- protected cameras?: Map;
+ public cameras?: Map;
@property({ attribute: false })
- protected liveConfig?: LiveConfig;
+ public liveConfig?: LiveConfig;
+
+ @property({ attribute: false, hasChanged: contentsChanged })
+ public liveOverrides?: LiveOverrides;
@property({ attribute: false })
- protected liveOverrides?: LiveOverrides;
+ public inBackground?: boolean;
@property({ attribute: false })
- protected preloaded?: boolean;
-
- @property({ attribute: false })
- protected conditionState?: ConditionState;
+ public conditionState?: ConditionState;
// Index between camera name and slide number.
protected _cameraToSlide: Record = {};
+ protected _refMediaCarousel: Ref = createRef();
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
- if (
- this._carousel &&
- (changedProperties.has('cameras') || changedProperties.has('liveConfig'))
- ) {
- // All of these properties may fundamentally change the contents/size of
- // the DOM, and the carousel should be reset when they change.
- this._destroyCarousel();
- }
-
super.updated(changedProperties);
+ const frigateCardMediaCarousel = this._refMediaCarousel.value;
+ const frigateCardCarousel = frigateCardMediaCarousel?.frigateCardCarousel();
+
if (changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined;
if (
- this._carousel &&
- oldView &&
+ frigateCardCarousel &&
this.view?.camera &&
- this.view?.camera != oldView.camera
+ (!oldView || this.view?.camera !== oldView.camera)
) {
const slide: number | undefined = this._cameraToSlide[this.view.camera];
- if (slide !== undefined && slide !== this.carouselSelected()) {
- this.carouselScrollTo(slide);
+ if (
+ slide !== undefined &&
+ slide !== frigateCardCarousel.getCarouselSelected()?.index
+ ) {
+ frigateCardCarousel.carouselScrollTo(slide);
}
}
}
- if (changedProperties.has('preloaded')) {
- const automedia = this._plugins['AutoMediaPlugin'] as
- | AutoMediaPluginType
- | undefined;
- if (automedia) {
- // If this has changed to preloaded then pause & mute, otherwise play
- // and potentially unmute (depending on configuration).
- if (this.preloaded) {
- automedia.pause();
- automedia.mute();
- } else {
- automedia.play();
- this._autoUnmuteHandler();
- }
+ if (
+ frigateCardMediaCarousel &&
+ frigateCardCarousel &&
+ changedProperties.has('inBackground')
+ ) {
+ // If this has changed to be in the background (i.e. preloaded but not
+ // visible) take the appropriate play/pause/mute/unmute actions.
+ if (this.inBackground) {
+ frigateCardMediaCarousel.autoPause();
+ frigateCardMediaCarousel.autoMute();
+ } else {
+ frigateCardMediaCarousel.autoPlay();
+ frigateCardMediaCarousel.autoUnmute();
}
}
}
@@ -319,8 +335,11 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
- protected _getTransitionEffect(): TransitionEffect | undefined {
- return this.liveConfig?.transition_effect;
+ protected _getTransitionEffect(): TransitionEffect {
+ return (
+ this.liveConfig?.transition_effect ??
+ frigateCardConfigDefaults.live.transition_effect
+ );
}
/**
@@ -328,13 +347,11 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
- let startIndex = -1;
- if (this.cameras && this.view) {
- startIndex = Array.from(this.cameras.keys()).indexOf(this.view.camera);
- }
-
return {
- startIndex: startIndex < 0 ? undefined : startIndex,
+ startIndex:
+ this.cameras && this.view
+ ? Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera))
+ : 0,
draggable: this.liveConfig?.draggable,
loop: true,
};
@@ -342,34 +359,48 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
/**
* Get the Embla plugins to use.
- * @returns An EmblaOptionsType object or undefined for no options.
+ * @returns A list of EmblaOptionsTypes.
*/
- protected _getPlugins(): EmblaPluginType[] | undefined {
+ protected _getPlugins(): EmblaCarouselPlugins {
return [
+ // Only enable wheel plugin if there is more than one camera.
+ ...(this.cameras && this.cameras.size > 1
+ ? [
+ WheelGesturesPlugin({
+ // Whether the carousel is vertical or horizontal, interpret y-axis wheel
+ // gestures as scrolling for the carousel.
+ forceWheelAxis: 'y',
+ }),
+ ]
+ : []),
Lazyload({
- lazyloadCallback: this.liveConfig?.lazy_load
- ? (...args) => this._lazyloadOrUnloadSlide('load', ...args)
- : undefined,
- lazyunloadCallback: this.liveConfig?.lazy_unload
- ? (...args) => this._lazyloadOrUnloadSlide('unload', ...args)
- : undefined,
+ ...(this.liveConfig?.lazy_load && {
+ lazyLoadCallback: (index, slide) =>
+ this._lazyloadOrUnloadSlide('load', index, slide),
+ }),
+
+ lazyUnloadCondition: this.liveConfig?.lazy_unload,
+ lazyUnloadCallback: (index, slide) =>
+ this._lazyloadOrUnloadSlide('unload', index, slide),
}),
AutoMediaPlugin({
playerSelector: 'frigate-card-live-provider',
- autoUnmuteWhenVisible: !!this.liveConfig?.auto_unmute,
+ ...(this.liveConfig?.auto_play && {
+ autoPlayCondition: this.liveConfig.auto_play,
+ }),
+ ...(this.liveConfig?.auto_pause && {
+ autoPauseCondition: this.liveConfig.auto_pause,
+ }),
+ ...(this.liveConfig?.auto_mute && {
+ autoMuteCondition: this.liveConfig.auto_mute,
+ }),
+ ...(this.liveConfig?.auto_unmute && {
+ autoUnmuteCondition: this.liveConfig.auto_unmute,
+ }),
}),
];
}
- /**
- * Unmute the media on the selected slide.
- */
- protected _autoUnmuteHandler(): void {
- if (this.liveConfig?.auto_unmute) {
- super._autoUnmuteHandler();
- }
- }
-
/**
* Returns the number of slides to lazily load. 0 means all slides are lazy
* loaded, 1 means that 1 slide on each side of the currently selected slide
@@ -408,17 +439,25 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
/**
* Handle the user selecting a new slide in the carousel.
*/
- protected _selectSlideSetViewHandler(): void {
- if (!this._carousel || !this.view || !this.cameras) {
+ protected _setViewHandler(): void {
+ const selectedCameraIndex = this._refMediaCarousel.value
+ ?.frigateCardCarousel()
+ ?.getCarouselSelected()?.index;
+ if (selectedCameraIndex === undefined || !this.view || !this.cameras) {
return;
}
- const selectedSnap = this._carousel.selectedScrollSnap();
this.view
.evolve({
- camera: Array.from(this.cameras.keys())[selectedSnap],
- previous: this.view,
+ camera: Array.from(this.cameras.keys())[selectedCameraIndex],
+
+ // Reset the target.
+ target: null,
+ childIndex: null,
})
+ // Don't yet fetch thumbnails (they will be fetched when the carousel
+ // settles).
+ .mergeInContext({ thumbnails: { fetch: false } })
.dispatchChangeEvent(this);
}
@@ -430,13 +469,17 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
protected _lazyloadOrUnloadSlide(
action: 'load' | 'unload',
_index: number,
- slide: HTMLElement,
+ slide: Element,
): void {
- const liveProvider = slide.querySelector(
+ if (slide instanceof HTMLSlotElement) {
+ slide = slide.assignedElements({ flatten: true })[0];
+ }
+
+ const liveProvider = slide?.querySelector(
'frigate-card-live-provider',
) as FrigateCardLiveProvider;
if (liveProvider) {
- liveProvider.disabled = action == 'load' ? false : true;
+ liveProvider.disabled = action !== 'load';
}
}
@@ -451,10 +494,10 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
// The conditionState object contains the currently live camera, which (in
// the carousel for example) is not necessarily the live camera this
// is rendering right now.
- const conditionState = Object.assign({
+ const conditionState = {
...this.conditionState,
camera: camera,
- });
+ };
const config = getOverriddenConfig(
this.liveConfig,
@@ -462,18 +505,24 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
conditionState,
) as LiveConfig;
- return html`
- ) =>
- this._mediaShowEventHandler(slideIndex, e)}
- >
-
-
`;
+ return html`
+
+ ) => {
+ wrapMediaLoadedEventForCarousel(slideIndex, ev);
+ }}
+ @frigate-card:media:unloaded=${(ev: CustomEvent) => {
+ wrapMediaUnloadedEventForCarousel(slideIndex, ev);
+ }}
+ >
+
+
+ `;
}
protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] {
@@ -498,30 +547,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
return [prev, next];
}
- /**
- * Handle updating of the next/previous controls when the carousel is moved.
- */
- protected _selectSlideNextPreviousHandler(): void {
- const updateNextPreviousControl = (
- control: FrigateCardNextPreviousControl,
- direction: 'previous' | 'next',
- ): void => {
- const [prev, next] = this._getCameraNeighbors();
- const target = direction == 'previous' ? prev : next;
-
- control.disabled = target == null;
- control.title = getCameraTitle(this.hass, target);
- control.icon = getCameraIcon(this.hass, target);
- };
-
- if (this._previousControlRef.value) {
- updateNextPreviousControl(this._previousControlRef.value, 'previous');
- }
- if (this._nextControlRef.value) {
- updateNextPreviousControl(this._nextControlRef.value, 'next');
- }
- }
-
/**
* Render the element.
* @returns A template to display to the user.
@@ -529,7 +554,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
protected render(): TemplateResult | void {
const [slides, cameraToSlide] = this._getSlides();
this._cameraToSlide = cameraToSlide;
- if (!slides || !this.liveConfig || !this.cameras || !this.view) {
+ if (!slides.length || !this.liveConfig || !this.cameras || !this.view) {
return;
}
@@ -542,59 +567,89 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
const [prev, next] = this._getCameraNeighbors();
const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera));
+ // Notes on the below:
+ // - guard() is used to avoid reseting the carousel unless the
+ // options/plugins actually change.
+ // - the 'carousel:settle' event is listened for (instead of
+ // 'carousel:select') to only trigger the view change (which subsequently
+ // fetches thumbnails) after the carousel has stopped moving. This gives a
+ // much smoother carousel experience since network fetches are not at the
+ // same time as carousel movement (at a cost of fetching thumbnails a
+ // little later).
+
return html`
-
+
{
+ // Fetch the thumbnails after the carousel has settled.
+ dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
+ }}
+ >
{
- this._nextPreviousHandler('previous');
+ this._refMediaCarousel.value
+ ?.frigateCardCarousel()
+ ?.carouselScrollPrevious();
stopEventFromActivatingCardWideActions(ev);
}}
>
-
+ ${slides}
{
- this._nextPreviousHandler('next');
+ this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
stopEventFromActivatingCardWideActions(ev);
}}
>
-
-
-
+
`;
}
+
+ /**
+ * Get styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(liveCarouselStyle);
+ }
}
@customElement('frigate-card-live-provider')
export class FrigateCardLiveProvider extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- protected cameraConfig?: CameraConfig;
+ public cameraConfig?: CameraConfig;
@property({ attribute: false })
- protected liveConfig?: LiveConfig;
+ public liveConfig?: LiveConfig;
// Whether or not to disable this entity. If `true`, no contents are rendered
// until this attribute is set to `false` (this is useful for lazy loading).
@@ -605,6 +660,9 @@ export class FrigateCardLiveProvider extends LitElement {
@property({ attribute: false })
public label = '';
+ @state()
+ protected _isVideoMediaLoaded = false;
+
protected _providerRef: Ref = createRef();
/**
@@ -635,7 +693,18 @@ export class FrigateCardLiveProvider extends LitElement {
this._providerRef.value?.unmute();
}
- protected _getResolvedProvider(): LiveProvider {
+ /**
+ * Seek the video.
+ */
+ public seek(seconds: number): void {
+ this._providerRef.value?.seek(seconds);
+ }
+
+ /**
+ * Get the fully resolved live provider.
+ * @returns A live provider (that is not 'auto').
+ */
+ protected _getResolvedProvider(): Omit {
if (this.cameraConfig?.live_provider === 'auto') {
if (
this.cameraConfig?.webrtc_card?.entity ||
@@ -644,7 +713,7 @@ export class FrigateCardLiveProvider extends LitElement {
return 'webrtc-card';
} else if (this.cameraConfig?.camera_entity) {
return 'ha';
- } else if (this.cameraConfig?.camera_name) {
+ } else if (this.cameraConfig?.frigate.camera_name) {
return 'frigate-jsmpeg';
}
return frigateCardConfigDefaults.cameras.live_provider;
@@ -654,6 +723,48 @@ export class FrigateCardLiveProvider extends LitElement {
);
}
+ /**
+ * Determine if a camera image should be shown in lieu of the real stream
+ * whilst loading.
+ * @returns`true` if an image should be shown.
+ */
+ protected _shouldShowImageDuringLoading(): boolean {
+ return (
+ !!this.cameraConfig?.camera_entity &&
+ !!this.hass &&
+ !!this.liveConfig?.show_image_during_load
+ );
+ }
+
+ /**
+ * Component disconnected callback.
+ */
+ disconnectedCallback(): void {
+ this._isVideoMediaLoaded = false;
+ }
+
+ /**
+ * Record that video media is being shown.
+ */
+ protected _videoMediaShowHandler(): void {
+ this._isVideoMediaLoaded = true;
+ }
+
+ /**
+ * Called before each update.
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('disabled')) {
+ if (this.disabled) {
+ this._isVideoMediaLoaded = false;
+ dispatchMediaUnloadedEvent(this);
+ }
+ }
+ if (changedProps.has('liveConfig')) {
+ updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout);
+ }
+ }
+
/**
* Master render method.
* @returns A rendered template.
@@ -668,41 +779,69 @@ export class FrigateCardLiveProvider extends LitElement {
this.ariaLabel = this.label;
const provider = this._getResolvedProvider();
+ const showImage = !this._isVideoMediaLoaded && this._shouldShowImageDuringLoading();
+ const providerClasses = {
+ hidden: showImage,
+ };
return html`
- ${provider == 'ha'
- ? html`
+ `
+ : html``}
+ ${provider === 'ha'
+ ? html`
`
- : provider == 'webrtc-card'
+ : provider === 'webrtc-card'
? html`
`
: html`
`}
`;
}
+
+ /**
+ * Get styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(liveProviderStyle);
+ }
}
@customElement('frigate-card-live-ha')
export class FrigateCardLiveFrigate extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: HomeAssistant;
@property({ attribute: false })
- protected cameraConfig?: CameraConfig;
+ public cameraConfig?: CameraConfig;
protected _playerRef: Ref
= createRef();
@@ -734,6 +873,13 @@ export class FrigateCardLiveFrigate extends LitElement {
this._playerRef.value?.unmute();
}
+ /**
+ * Seek the video.
+ */
+ public seek(seconds: number): void {
+ this._playerRef.value?.seek(seconds);
+ }
+
/**
* Master render method.
* @returns A rendered template.
@@ -744,19 +890,28 @@ export class FrigateCardLiveFrigate extends LitElement {
}
if (!this.cameraConfig?.camera_entity) {
- return dispatchErrorMessageEvent(
- this,
- localize('error.no_live_camera'),
- this.cameraConfig,
- );
+ return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), {
+ context: this.cameraConfig,
+ });
}
const stateObj = this.hass.states[this.cameraConfig.camera_entity];
- if (!stateObj || stateObj.state === 'unavailable') {
- return dispatchErrorMessageEvent(
+ if (!stateObj) {
+ return dispatchErrorMessageEvent(this, localize('error.live_camera_not_found'), {
+ context: this.cameraConfig,
+ });
+ }
+
+ if (stateObj.state === 'unavailable') {
+ // Don't treat state unavailability as an error per se.
+ return dispatchMessageEvent(
this,
localize('error.live_camera_unavailable'),
- this.cameraConfig,
+ 'info',
+ {
+ icon: 'mdi:connection',
+ context: getCameraTitle(this.hass, this.cameraConfig),
+ },
);
}
@@ -783,12 +938,12 @@ export class FrigateCardLiveFrigate extends LitElement {
@customElement('frigate-card-live-webrtc-card')
export class FrigateCardLiveWebRTCCard extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged })
- protected webRTCConfig?: WebRTCCardConfig;
+ public webRTCConfig?: WebRTCCardConfig;
@property({ attribute: false })
- protected cameraConfig?: CameraConfig;
+ public cameraConfig?: CameraConfig;
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ protected hass?: HomeAssistant;
// A task to await the load of the WebRTC component.
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
@@ -833,6 +988,16 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
}
}
+ /**
+ * Seek the video.
+ */
+ public seek(seconds: number): void {
+ const player = this._getPlayer();
+ if (player) {
+ player.currentTime = seconds;
+ }
+ }
+
/**
* Get the underlying video player.
* @returns The player or `null` if not found.
@@ -851,7 +1016,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
/**
* Create the WebRTC element. May throw.
*/
- protected _createWebRTC(): HTMLElement | undefined {
+ protected _createWebRTC(): HTMLElement | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const webrtcElement = this._webrtcTask.value;
if (webrtcElement && this.hass) {
@@ -874,7 +1039,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
webrtc.hass = this.hass;
return webrtc;
}
- return undefined;
+ return null;
}
/**
@@ -883,29 +1048,33 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
*/
protected render(): TemplateResult | void {
const render = (): TemplateResult | void => {
- let webrtcElement: HTMLElement | undefined;
+ let webrtcElement: HTMLElement | null;
try {
webrtcElement = this._createWebRTC();
} catch (e) {
return dispatchErrorMessageEvent(
this,
e instanceof FrigateCardError
- ? (e as FrigateCardError).message
+ ? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
+ { context: (e as FrigateCardError).context },
);
}
+ if (webrtcElement) {
+ // Set the id to ensure that the relevant CSS styles will have
+ // sufficient specifity to overcome some styles that are otherwise
+ // applied to in Safari.
+ webrtcElement.id = 'webrtc';
+ }
return html`${webrtcElement}`;
};
// Use a task to allow us to asynchronously wait for the WebRTC card to
// load, but yet still have the card load be followed by the updated()
// lifecycle callback (unlike just using `until`).
- return html`${this._webrtcTask.render({
- initial: () => renderProgressIndicator(localize('error.webrtc_card_waiting')),
- pending: () => renderProgressIndicator(localize('error.webrtc_card_waiting')),
- error: (e: unknown) => dispatchErrorMessageEvent(this, (e as Error).message),
- complete: () => render(),
- })}`;
+ return renderTask(this, this._webrtcTask, render, () =>
+ renderProgressIndicator(localize('error.webrtc_card_waiting')),
+ );
}
/**
@@ -917,13 +1086,13 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
this.updateComplete.then(() => {
const video = this._getPlayer();
if (video) {
- const onloadedmetadata = video.onloadedmetadata;
+ const onloadeddata = video.onloadeddata;
- video.onloadedmetadata = (e) => {
- if (onloadedmetadata) {
- onloadedmetadata.call(video, e);
+ video.onloadeddata = (e) => {
+ if (onloadeddata) {
+ onloadeddata.call(video, e);
}
- dispatchMediaShowEvent(this, video);
+ dispatchMediaLoadedEvent(this, video);
};
}
});
@@ -940,12 +1109,12 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
@customElement('frigate-card-live-jsmpeg')
export class FrigateCardLiveJSMPEG extends LitElement {
@property({ attribute: false })
- protected cameraConfig?: CameraConfig;
+ public cameraConfig?: CameraConfig;
@property({ attribute: false, hasChanged: contentsChanged })
- protected jsmpegConfig?: JSMPEGConfig;
+ public jsmpegConfig?: JSMPEGConfig;
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ protected hass?: ExtendedHomeAssistant;
protected _jsmpegCanvasElement?: HTMLCanvasElement;
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
@@ -987,12 +1156,24 @@ export class FrigateCardLiveJSMPEG extends LitElement {
}
}
+ /**
+ * Seek the video (unsupported).
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ public seek(_seconds: number): void {
+ // JSMPEG does not support seeking.
+ }
+
/**
* Get a signed player URL.
* @returns A URL or null.
*/
protected async _getURL(): Promise {
- if (!this.hass || !this.cameraConfig?.client_id || !this.cameraConfig?.camera_name) {
+ if (
+ !this.hass ||
+ !this.cameraConfig?.frigate.client_id ||
+ !this.cameraConfig?.frigate.camera_name
+ ) {
return null;
}
@@ -1000,12 +1181,12 @@ export class FrigateCardLiveJSMPEG extends LitElement {
try {
response = await homeAssistantSignPath(
this.hass,
- `/api/frigate/${this.cameraConfig.client_id}` +
- `/jsmpeg/${this.cameraConfig.camera_name}`,
+ `/api/frigate/${this.cameraConfig.frigate.client_id}` +
+ `/jsmpeg/${this.cameraConfig.frigate.camera_name}`,
URL_SIGN_EXPIRY_SECONDS,
);
- } catch (err) {
- console.warn(err);
+ } catch (e) {
+ errorToConsole(e as Error);
return null;
}
if (!response) {
@@ -1029,9 +1210,10 @@ export class FrigateCardLiveJSMPEG extends LitElement {
canvas: this._jsmpegCanvasElement,
},
{
- // The media carousel automatically pauses when the browser tab is
+ // The media carousel may automatically pause when the browser tab is
// inactive, JSMPEG does not need to also do so independently.
pauseWhenHidden: false,
+ autoplay: false,
protocols: [],
audio: false,
videoBufferSize: 1024 * 1024 * 4,
@@ -1049,7 +1231,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
// ignore any subsequent calls.
if (!videoDecoded && this._jsmpegCanvasElement) {
videoDecoded = true;
- dispatchMediaShowEvent(this, this._jsmpegCanvasElement);
+ dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
resolve(player);
}
},
@@ -1109,11 +1291,10 @@ export class FrigateCardLiveJSMPEG extends LitElement {
this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media';
- if (!this.cameraConfig?.camera_name) {
- return dispatchErrorMessageEvent(
- this,
- localize('error.no_camera_name') + `: ${JSON.stringify(this.cameraConfig)}`,
- );
+ if (!this.cameraConfig?.frigate.camera_name) {
+ return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), {
+ context: this.cameraConfig,
+ });
}
const url = await this._getURL();
@@ -1149,3 +1330,14 @@ export class FrigateCardLiveJSMPEG extends LitElement {
return unsafeCSS(liveJSMPEGStyle);
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG;
+ 'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard;
+ 'frigate-card-live-ha': FrigateCardLiveFrigate;
+ 'frigate-card-live-provider': FrigateCardLiveProvider;
+ 'frigate-card-live-carousel': FrigateCardLiveCarousel;
+ 'frigate-card-live': FrigateCardLive;
+ }
+}
diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts
index fa6afa4f..8f7c7280 100644
--- a/src/components/media-carousel.ts
+++ b/src/components/media-carousel.ts
@@ -1,59 +1,240 @@
-import { CSSResultGroup, unsafeCSS } from 'lit';
-import { EmblaCarouselType } from 'embla-carousel';
-import { createRef, Ref } from 'lit/directives/ref.js';
-import { customElement } from 'lit/decorators.js';
-
-import { AutoMediaPluginType } from './embla-plugins/automedia.js';
-import { FrigateCardCarousel } from './carousel.js';
+import { EmblaOptionsType } from 'embla-carousel';
+import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { ifDefined } from 'lit/directives/if-defined.js';
+import { createRef, ref, Ref } from 'lit/directives/ref.js';
+import mediaCarouselStyle from '../scss/media-carousel.scss';
+import type {
+ MediaLoadedInfo,
+ NextPreviousControlConfig,
+ TitleControlConfig,
+ TransitionEffect,
+} from '../types.js';
+import { dispatchFrigateCardEvent } from '../utils/basic';
+import {
+ createMediaLoadedInfo,
+ dispatchExistingMediaLoadedInfoAsEvent,
+ isValidMediaLoadedInfo,
+} from '../utils/media-info.js';
+import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel';
+import { AutoMediaType } from './embla-plugins/automedia.js';
+import './next-prev-control.js';
+import './carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js';
-import type { MediaShowInfo } from '../types.js';
-import {
- dispatchExistingMediaShowInfoAsEvent,
- isValidMediaShowInfo,
-} from '../common.js';
-
-import './next-prev-control.js';
-
-import mediaCarouselStyle from '../scss/media-carousel.scss';
const getEmptyImageSrc = (width: number, height: number) =>
`data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`;
export const IMG_EMPTY = getEmptyImageSrc(16, 9);
+export interface CarouselMediaLoadedInfo {
+ slide: number;
+ mediaLoadedInfo: MediaLoadedInfo;
+}
+
+export interface CarouselMediaUnloadedInfo {
+ slide: number;
+}
+
+/**
+ * Dispatch a carousel media loaded event.
+ * @param target The target to send it from.
+ * @param carouselMediaLoadedInfo The CarouselMediaLoadedInfo.
+ */
+const dispatchFrigateCardCarouselMediaLoaded = (
+ target: EventTarget,
+ carouselMediaLoadedInfo: CarouselMediaLoadedInfo,
+): void => {
+ dispatchFrigateCardEvent(
+ target,
+ 'carousel:media:loaded',
+ carouselMediaLoadedInfo,
+ );
+};
+
+/**
+ * Dispatch a carousel media UNloaded event.
+ * @param target The target to send it from.
+ * @param carouselMediaUnloadedInfo The CarouselMediaUnloadedInfo.
+ */
+const dispatchFrigateCardCarouselMediaUnloaded = (
+ target: EventTarget,
+ carouselMediaUnloadedInfo: CarouselMediaUnloadedInfo,
+): void => {
+ dispatchFrigateCardEvent(
+ target,
+ 'carousel:media:unloaded',
+ carouselMediaUnloadedInfo,
+ );
+};
+
+/**
+ * Turn a MediaLoadedInfo into a CarouselMediaLoadedInfo.
+ * @param slide The slide number.
+ * @param event The MediaShowEvent.
+ */
+export const wrapMediaLoadedEventForCarousel = (
+ slide: number,
+ event: CustomEvent,
+) => {
+ event.stopPropagation();
+ dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], {
+ slide: slide,
+ mediaLoadedInfo: event.detail,
+ });
+};
+
+/**
+ * Turn a (raw, e.g. img) media load event into a CarouselMediaLoadedInfo.
+ * @param slide The slide number.
+ * @param event The MediaShowEvent.
+ */
+export const wrapRawMediaLoadedEventForCarousel = (slide: number, event: Event) => {
+ const mediaLoadedInfo = createMediaLoadedInfo(event);
+ if (mediaLoadedInfo) {
+ dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], {
+ slide: slide,
+ mediaLoadedInfo: mediaLoadedInfo,
+ });
+ }
+};
+
+/**
+ * Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo.
+ * @param slide The slide number.
+ * @param event The MediaUnloadedEvent.
+ */
+export const wrapMediaUnloadedEventForCarousel = (
+ slide: number,
+ event: CustomEvent,
+) => {
+ event.stopPropagation();
+ dispatchFrigateCardCarouselMediaUnloaded(event.composedPath()[0], {
+ slide: slide,
+ });
+};
+
@customElement('frigate-card-media-carousel')
-export class FrigateCardMediaCarousel extends FrigateCardCarousel {
- // A "map" from slide number to MediaShowInfo object.
- protected _mediaShowInfo: Record = {};
+export class FrigateCardMediaCarousel extends LitElement {
+ @property({ attribute: false })
+ public nextPreviousConfig?: NextPreviousControlConfig;
+
+ @property({ attribute: false })
+ public carouselOptions?: EmblaOptionsType;
+
+ @property({ attribute: false })
+ public carouselPlugins?: EmblaCarouselPlugins;
+
+ @property({ attribute: true })
+ public transitionEffect?: TransitionEffect;
+
+ @property({ attribute: false })
+ public label?: string;
+
+ @property({ attribute: false })
+ public titlePopupConfig?: TitleControlConfig;
+
+ // A "map" from slide number to MediaLoadedInfo object.
+ protected _mediaLoadedInfo: Record = {};
protected _nextControlRef: Ref = createRef();
protected _previousControlRef: Ref = createRef();
protected _titleControlRef: Ref = createRef();
protected _titleTimerID: number | null = null;
+ protected _boundAutoPlayHandler = this.autoPlay.bind(this);
+ protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
+ protected _boundAdaptContainerHeightToSlide =
+ this._adaptContainerHeightToSlide.bind(this);
+ protected _boundTitleHandler = this._titleHandler.bind(this);
+
// This carousel may be resized by Lovelace resizes, window resizes,
// fullscreen, etc. Always call the adaptive height handler when the size
// changes.
protected _resizeObserver: ResizeObserver;
+ protected _slideResizeObserver: ResizeObserver;
+ protected _intersectionObserver: IntersectionObserver;
+
+ protected _refCarousel: Ref = createRef();
constructor() {
super();
- this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this));
+ // Need to watch both changes in this element (e.g. caused by a window
+ // resize or fullscreen change) and changes in the selected slide itself
+ // (e.g. changing from a progress indicator to a loaded media).
+ this._resizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this));
+ this._slideResizeObserver = new ResizeObserver(
+ this._reInitAndAdjustHeight.bind(this),
+ );
+ this._intersectionObserver = new IntersectionObserver(
+ this._intersectionHandler.bind(this),
+ );
}
/**
- * Play the media on the selected slide. May be overridden to control when
- * autoplay should happen.
+ * Get the underlying carousel.
*/
- protected _autoPlayHandler(): void {
- (this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.play();
+ public frigateCardCarousel(): FrigateCardCarousel | null {
+ return this._refCarousel.value ?? null;
}
/**
- * Unmute the media on the selected slide. May be overridden to control when
- * autoplay should happen.
+ * Get the AutoMedia plugin (if any).
+ * @returns The plugin or `null`.
*/
- protected _autoUnmuteHandler(): void {
- (this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.unmute();
+ protected _getAutoMediaPlugin(): AutoMediaType | null {
+ return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null;
+ }
+
+ /**
+ * Play the media on the selected slide.
+ */
+ public autoPlay(): void {
+ const automediaOptions = this._getAutoMediaPlugin()?.options;
+ if (
+ automediaOptions?.autoPlayCondition &&
+ ['all', 'selected'].includes(automediaOptions?.autoPlayCondition)
+ ) {
+ this._getAutoMediaPlugin()?.play();
+ }
+ }
+
+ /**
+ * Pause the media on the selected slide.
+ */
+ public autoPause(): void {
+ const automediaOptions = this._getAutoMediaPlugin()?.options;
+ if (
+ automediaOptions?.autoPauseCondition &&
+ ['all', 'selected'].includes(automediaOptions.autoPauseCondition)
+ ) {
+ this._getAutoMediaPlugin()?.pause();
+ }
+ }
+
+ /**
+ * Unmute the media on the selected slide.
+ */
+ public autoUnmute(): void {
+ const automediaOptions = this._getAutoMediaPlugin()?.options;
+ if (
+ automediaOptions?.autoUnmuteCondition &&
+ ['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition)
+ ) {
+ this._getAutoMediaPlugin()?.unmute();
+ }
+ }
+
+ /**
+ * Mute the media on the selected slide.
+ */
+ public autoMute(): void {
+ const automediaOptions = this._getAutoMediaPlugin()?.options;
+ if (
+ automediaOptions?.autoMuteCondition &&
+ ['all', 'selected'].includes(automediaOptions?.autoMuteCondition)
+ ) {
+ this._getAutoMediaPlugin()?.mute();
+ }
}
/**
@@ -77,8 +258,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
// Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title,
- // and allows for the browser to finish rendering the carousel (inc.
- // adaptive height which has `0.5s ease`, see `media-carousel.scss`).
+ // and allows for the browser to finish rendering the carousel.
this._titleTimerID = window.setTimeout(show, 0.5 * 1000);
}
@@ -87,83 +267,88 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
*/
connectedCallback(): void {
super.connectedCallback();
- this.addEventListener('frigate-card:media-show', this._autoPlayHandler);
- this.addEventListener('frigate-card:media-show', this._autoUnmuteHandler);
- this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
- this.addEventListener('frigate-card:media-show', this._titleHandler);
+
+ this.addEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
+ this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
+ this.addEventListener(
+ 'frigate-card:media:loaded',
+ this._boundAdaptContainerHeightToSlide,
+ );
+ this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._resizeObserver.observe(this);
+ this._intersectionObserver.observe(this);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
- super.disconnectedCallback();
- this.removeEventListener('frigate-card:media-show', this._autoPlayHandler);
- this.removeEventListener('frigate-card:media-show', this._autoUnmuteHandler);
- this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
- this.removeEventListener('frigate-card:media-show', this._titleHandler);
+ this.removeEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
+ this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
+ this.removeEventListener(
+ 'frigate-card:media:loaded',
+ this._boundAdaptContainerHeightToSlide,
+ );
+ this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._resizeObserver.disconnect();
- }
+ this._intersectionObserver.disconnect();
- protected _destroyCarousel(): void {
- super._destroyCarousel();
-
- // Notes on instance variables:
- // * this._mediaShowInfo: This is set when the media in the DOM loads. If a
- // new View included the same media, the DOM would not change and so the
- // prior contents would still be valid and would not re-appear (as the
- // media would not reload) -- as such, leave this alone on carousel
- // destroy. New media in that slide will replace the prior contents on
- // load.
+ this._mediaLoadedInfo = {};
+ super.disconnectedCallback();
}
/**
- * Initialize the carousel.
+ * ReInit the carousel and adapt the container height.
*/
- protected _initCarousel(): void {
- super._initCarousel();
-
- // Necessary because typescript local type narrowing is not paying attention
- // to the side-effect of the call to super._initCarousel().
- const carousel = this._carousel as EmblaCarouselType | undefined;
-
- // Update the view object as the carousel is moved.
- carousel?.on('select', this._selectSlideSetViewHandler.bind(this));
-
- // Update the next/previous controls as the carousel is moved.
- carousel?.on('select', this._selectSlideNextPreviousHandler.bind(this));
-
- // Dispatch MediaShow events as the carousel is moved.
- carousel?.on('init', this._selectSlideMediaShowHandler.bind(this));
- carousel?.on('select', this._selectSlideMediaShowHandler.bind(this));
+ protected _reInitAndAdjustHeight(): void {
+ this.frigateCardCarousel()?.carouselReInitWhenSafe();
+ this._adaptContainerHeightToSlide();
}
/**
- * Set the the height of the container on media load in case the dimensions
+ * Called when the carousel intersects with the viewport.
+ * @param entries The IntersectionObserverEntry entries (should be only 1).
+ */
+ protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
+ /**
+ * - If the DOM that contains this carousel changes such that it causes
+ * slides to entirely appear/disappear (e.g. `display: none` or hidden),
+ * then the displayed slide sizes will significantly change and the
+ * carousel will need to be reinitialized. Without this, odd bugs may
+ * occur for some users in some circumstances causing the carousel to
+ * appear 'stuck'.
+ * - Example bug when this reinitialization is not performed:
+ * https://github.com/dermotduffy/frigate-hass-card/issues/651
+ */
+ if (entries.some((entry) => entry.isIntersecting)) {
+ this._reInitAndAdjustHeight();
+ }
+ }
+
+ /**
+ * Set the the height of the component on media load in case the dimensions
* have changed. This handler is not triggered from carousel events, as it's
* actually the media load/show that will change the dimensions, and that is
* async from carousel actions (e.g. lazy-loaded media).
+ *
+ * This component does not use the stock Embla auto-height plugin as it
+ * resizes the container on selection rather than media load.
*/
- protected _adaptiveHeightHandler(): void {
+ protected _adaptContainerHeightToSlide(): void {
const adaptCarouselHeight = (): void => {
- if (!this._carousel) {
- return;
- }
- const slide = this._carousel?.selectedScrollSnap();
- if (slide !== undefined) {
- this._carousel.containerNode().style.removeProperty('max-height');
- const slides = this._carousel.slideNodes();
- const height = slides[slide].getBoundingClientRect().height;
- if (height > 0) {
- this._carousel.containerNode().style.maxHeight = `${height}px`;
+ const selected = this.frigateCardCarousel()?.getCarouselSelected();
+ if (selected) {
+ this.style.removeProperty('max-height');
+ const height = selected.element.getBoundingClientRect().height;
+ if (height !== undefined && height > 0) {
+ this.style.maxHeight = `${height}px`;
}
}
};
// Hack: This method attempts to measure the height of the selected slide in
// order to set the overall carousel height to match. This method is
- // triggered from `frigate-card:media-show` events, which are usually in
+ // triggered from `frigate-card:media:loaded` events, which are usually in
// turn triggered from media/metadata load events from media players.
// Sufficient time needs to be allowed after these metadata load events to
// allow the browser to repaint the element heights, so that we can get the
@@ -171,117 +356,103 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
window.requestAnimationFrame(adaptCarouselHeight);
}
- /**
- * Handle the user selecting a new slide in the carousel.
- */
- protected _selectSlideSetViewHandler(): void {
- // To be overridden in children.
- }
-
- /**
- * Handle updating of the next/previous controls when the carousel is moved.
- */
- protected _selectSlideNextPreviousHandler(): void {
- // To be overridden in children.
- }
-
- /**
- * Handle a next/previous control interaction.
- * @param direction The direction requested, previous or next.
- */
- protected _nextPreviousHandler(direction: 'previous' | 'next'): void {
- if (direction == 'previous') {
- this._carousel?.scrollPrev(this._getTransitionEffect() === 'none');
- } else if (direction == 'next') {
- this._carousel?.scrollNext(this._getTransitionEffect() === 'none');
- }
- }
-
/**
* Fire a media show event when a slide is selected.
*/
- protected _selectSlideMediaShowHandler(): void {
- if (!this._carousel) {
- return;
- }
-
- const slideIndex = this._carousel.selectedScrollSnap();
- if (slideIndex in this._mediaShowInfo) {
- dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]);
+ protected _dispatchMediaLoadedInfo(): void {
+ const slideIndex = this.frigateCardCarousel()?.getCarouselSelected()?.index;
+ if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) {
+ dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]);
}
}
/**
- * Handle a media-show event that is generated by a child component, saving the
+ * Handle a media:loaded event that is generated by a child component, saving the
* contents for future use when the relevant slide is actually shown.
* @param slideIndex The relevant slide index.
- * @param event The media-show event from the child component.
+ * @param event The media:loaded event from the child component.
*/
- protected _mediaShowEventHandler(
- slideIndex: number,
- event: CustomEvent,
- ): void {
+ protected _storeMediaLoadedInfo(event: CustomEvent): void {
// Don't allow the inbound event to propagate upwards, that will be
// automatically done at the appropriate time as the slide is shown.
event.stopPropagation();
- this._mediaLoadedHandler(slideIndex, event.detail);
+ const mediaLoadedInfo = event.detail.mediaLoadedInfo;
+ const slideIndex = event.detail.slide;
+
+ // isValidMediaLoadedInfo is used to prevent saving media info that will be
+ // rejected upstream (empty 1x1 images will be rejected here).
+ if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) {
+ this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo;
+ if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) {
+ dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
+ }
+ }
}
/**
- * Handle a MediaShowInfo object that is generated on media load, by saving it
- * for future, or immediate use, when the relevant slide is displayed.
- * @param slideIndex The relevant slide index.
- * @param mediaShowInfo The MediaShowInfo object generated by the media.
+ * Remove a media loaded info (i.e. a media item has unloaded).
+ * @param event The CarouselMediaUnloadedInfo event.
*/
- protected _mediaLoadedHandler(
- slideIndex: number,
- mediaShowInfo?: MediaShowInfo | null,
- ): void {
- // isValidMediaShowInfo is used to prevent saving media info that will be
- // rejected upstream (empty 1x1 images will be rejected here).
- if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) {
- this._mediaShowInfo[slideIndex] = mediaShowInfo;
- if (this._carousel && this._carousel?.selectedScrollSnap() == slideIndex) {
- dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo);
- }
+ protected _removeMediaLoadedInfo(event: CustomEvent): void {
+ const slideIndex = event.detail.slide;
+ delete this._mediaLoadedInfo[slideIndex];
- /**
- * Images need a width/height from initial load, and browsers will assume
- * that the aspect ratio of the initial dummy-image load will persist. In
- * lazy-loading, this can cause a 1x1 pixel dummy image to cause the
- * browser to assume all images will be square, so the whole carousel will
- * have the wrong aspect-ratio until every single image has been lazily
- * loaded. Adaptive height helps in that the carousel gets resized on each
- * img display to the correct size, but it still causes a minor noticeable
- * flicker until the height change is complete.
- *
- * To avoid this, we use a 16:9 dummy image at first (most
- * likely?) and once the first piece of real media has been loaded, all
- * dummy images are replaced with dummy images that match the aspect ratio
- * of the real image. It still might be wrong, but it's the best option
- * available.
- */
- const firstMediaLoad = !Object.keys(this._mediaShowInfo).length;
- if (firstMediaLoad) {
- const replacementImageSrc = getEmptyImageSrc(
- mediaShowInfo.width,
- mediaShowInfo.height,
- );
-
- this.renderRoot.querySelectorAll('.embla__container img').forEach((img) => {
- const imageElement = img as HTMLImageElement;
- if (imageElement.src === IMG_EMPTY) {
- imageElement.src = replacementImageSrc;
- }
- });
- }
+ // If the slide that unloaded is not visible, don't propagate the event upwards.
+ if (this.frigateCardCarousel()?.getCarouselSelected()?.index !== slideIndex) {
+ event.stopPropagation();
}
}
+ protected render(): TemplateResult | void {
+ return html` ) => {
+ this._slideResizeObserver.disconnect();
+ this._slideResizeObserver.observe(ev.detail.element);
+
+ // Pass up the media-carousel select event first to allow parents to
+ // initialize/reset before the media info is dispatched.
+ dispatchFrigateCardEvent(
+ this,
+ 'media-carousel:select',
+ ev.detail,
+ );
+
+ // Dispatch media info.
+ this._dispatchMediaLoadedInfo();
+ }}
+ @frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)}
+ @frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)}
+ >
+
+
+
+
+ ${this.label && this.titlePopupConfig
+ ? html`
+ `
+ : ``}`;
+ }
+
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
- return [super.styles, unsafeCSS(mediaCarouselStyle)];
+ return unsafeCSS(mediaCarouselStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-media-carousel': FrigateCardMediaCarousel;
}
}
diff --git a/src/components/menu.ts b/src/components/menu.ts
index 51af65a6..6201f4a7 100644
--- a/src/components/menu.ts
+++ b/src/components/menu.ts
@@ -1,53 +1,37 @@
-import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers';
+import {
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS
+} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
-
import { actionHandler } from '../action-handler-directive.js';
-
-import './submenu.js';
-
+import menuStyle from '../scss/menu.scss';
import type {
ActionsConfig,
ActionType,
- ExtendedHomeAssistant,
MenuButton,
MenuConfig,
- StateParameters,
+ MenuItem,
+ StateParameters
} from '../types.js';
import {
convertActionToFrigateCardCustomAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
- getActionConfigGivenAction,
- refreshDynamicStateParameters,
-} from '../common.js';
-
-import menuStyle from '../scss/menu.scss';
+ getActionConfigGivenAction
+} from '../utils/action.js';
+import { FRIGATE_ICON_SVG_PATH } from '../utils/frigate.js';
+import { refreshDynamicStateParameters } from '../utils/ha';
+import './submenu.js';
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
-export const FRIGATE_ICON_FILLED =
- 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' +
- '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' +
- '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' +
- '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' +
- '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' +
- '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' +
- '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' +
- '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' +
- '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' +
- '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' +
- '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' +
- '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' +
- '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' +
- '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' +
- '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' +
- 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' +
- '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' +
- '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' +
- '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z';
/**
* A menu for the FrigateCard.
@@ -55,22 +39,28 @@ export const FRIGATE_ICON_FILLED =
@customElement('frigate-card-menu')
export class FrigateCardMenu extends LitElement {
@property({ attribute: false })
- public hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: HomeAssistant;
+
+ @property({ attribute: true, type: Boolean, reflect: true })
+ public expanded = false;
set menuConfig(menuConfig: MenuConfig) {
this._menuConfig = menuConfig;
if (menuConfig) {
- this.style.setProperty('--frigate-card-menu-button-size', menuConfig.button_size);
+ this.style.setProperty(
+ '--frigate-card-menu-button-size',
+ `${menuConfig.button_size}px`,
+ );
}
- // Store the menu mode as an attribute (used for CSS attribute selectors).
- this.setAttribute('data-mode', menuConfig.mode);
+ // Store the menu style, position and alignment as attributes (used for
+ // styling).
+ this.setAttribute('data-style', menuConfig.style);
+ this.setAttribute('data-position', menuConfig.position);
+ this.setAttribute('data-alignment', menuConfig.alignment);
}
@state()
protected _menuConfig?: MenuConfig;
- @property({ attribute: true, type: Boolean, reflect: true })
- protected expanded = false;
-
@property({ attribute: false })
public buttons: MenuButton[] = [];
@@ -80,7 +70,7 @@ export class FrigateCardMenu extends LitElement {
* @returns `true` if the menu is hiding, `false` otherwise.
*/
static isHidingMenu(menuConfig: MenuConfig | undefined): boolean {
- return menuConfig?.mode.startsWith('hidden-') ?? false;
+ return menuConfig?.style === 'hidden' ?? false;
}
/**
@@ -193,24 +183,71 @@ export class FrigateCardMenu extends LitElement {
}
}
+ /**
+ * Ensure menu buttons are sorted before the render.
+ * @param changedProps The changed properties
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ const style = this._menuConfig?.style;
+ const sortButtons = (a: MenuItem, b: MenuItem): number => {
+ // If the menu is hidden, the Frigate button must come first.
+ if (style === 'hidden') {
+ if (a.icon === FRIGATE_BUTTON_MENU_ICON) {
+ return -1;
+ } else if (b.icon === FRIGATE_BUTTON_MENU_ICON) {
+ return 1;
+ }
+ }
+
+ // Otherwise sort by priority.
+ if (
+ a.priority === undefined ||
+ (b.priority !== undefined && b.priority > a.priority)
+ ) {
+ return 1;
+ }
+ if (
+ b.priority === undefined ||
+ (a.priority !== undefined && b.priority < a.priority)
+ ) {
+ return -1;
+ }
+ return 0;
+ };
+
+ if (changedProps.has('_menuConfig') || changedProps.has('buttons')) {
+ this.buttons.sort(sortButtons);
+ }
+ }
+
/**
* Render a button.
* @param button The button configuration to render.
* @returns A rendered template or void.
*/
protected _renderButton(button: MenuButton): TemplateResult | void {
- if (button.type == 'custom:frigate-card-menu-submenu') {
+ if (button.enabled === false) {
+ return;
+ }
+ if (button.type === 'custom:frigate-card-menu-submenu') {
return html`
`;
+ } else if (button.type === 'custom:frigate-card-menu-submenu-select') {
+ return html`
+ `;
}
let stateParameters: StateParameters = { ...button };
const svgPath =
- stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_FILLED : '';
+ stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
if (this.hass && button.type === 'custom:frigate-card-menu-state-icon') {
stateParameters = refreshDynamicStateParameters(this.hass, stateParameters);
@@ -263,18 +300,37 @@ export class FrigateCardMenu extends LitElement {
if (!this._menuConfig) {
return;
}
- const mode = this._menuConfig.mode;
-
- if (mode == 'none') {
+ const style = this._menuConfig.style;
+ if (style === 'none') {
return;
}
// If the hidden menu isn't expanded, only show the Frigate button.
- const buttons =
- !mode.startsWith('hidden-') || this.expanded
- ? this.buttons
+ const matchingButtons =
+ style !== 'hidden' || this.expanded
+ ? this.buttons.filter(
+ (button) => !button.alignment || button.alignment === 'matching',
+ )
: this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON);
- return html` ${buttons.map((button) => this._renderButton(button))} `;
+
+ const opposingButtons =
+ style !== 'hidden' || this.expanded
+ ? this.buttons.filter((button) => button.alignment === 'opposing')
+ : [];
+
+ const matchingStyle = {
+ flex: String(matchingButtons.length),
+ };
+ const opposingStyle = {
+ flex: String(opposingButtons.length),
+ };
+
+ return html`
+ ${matchingButtons.map((button) => this._renderButton(button))}
+
+
+ ${opposingButtons.map((button) => this._renderButton(button))}
+
`;
}
/**
@@ -284,3 +340,9 @@ export class FrigateCardMenu extends LitElement {
return unsafeCSS(menuStyle);
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "frigate-card-menu": FrigateCardMenu
+ }
+}
diff --git a/src/components/message.ts b/src/components/message.ts
index cc947653..33c7469c 100644
--- a/src/components/message.ts
+++ b/src/components/message.ts
@@ -1,38 +1,51 @@
-import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
+import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
-
-import { Message } from '../types.js';
+import { classMap } from 'lit/directives/class-map.js';
import { TROUBLESHOOTING_URL } from '../const.js';
import { localize } from '../localize/localize.js';
-
import messageStyle from '../scss/message.scss';
+import { FrigateCardError, Message, MessageType } from '../types.js';
+import { dispatchFrigateCardEvent } from '../utils/basic.js';
@customElement('frigate-card-message')
export class FrigateCardMessage extends LitElement {
@property({ attribute: false })
- protected message = '';
+ public message: string | TemplateResult<1> = '';
@property({ attribute: false })
- protected context?: unknown;
+ public context?: unknown;
@property({ attribute: false })
- protected icon?: string;
+ public icon?: string;
+
+ @property({ attribute: true, type: Boolean })
+ public dotdotdot?: boolean;
// Render the menu.
protected render(): TemplateResult {
const icon = this.icon ? this.icon : 'mdi:information-outline';
- return html`
-
-
-
-
-
-
-
${this.message ? html`${this.message}` : ''}
- ${this.context ? html`
${JSON.stringify(this.context, null, 2)}` : ''}
-
+ const classes = {
+ dotdotdot: !!this.dotdotdot,
+ };
+ return html`
+
`;
+
+
+ ${this.message
+ ? html`${this.message}${this.context && typeof this.context === 'string'
+ ? ': ' + this.context
+ : ''}`
+ : ''}
+
+ ${this.context && typeof this.context !== 'string'
+ ? html`
${JSON.stringify(this.context, null, 2)}`
+ : ''}
+
+
+
`;
}
static get styles(): CSSResultGroup {
@@ -43,7 +56,7 @@ export class FrigateCardMessage extends LitElement {
@customElement('frigate-card-error-message')
export class FrigateCardErrorMessage extends LitElement {
@property({ attribute: false })
- protected message?: Message;
+ public message?: Message;
protected render(): TemplateResult | void {
if (!this.message) {
@@ -54,6 +67,7 @@ export class FrigateCardErrorMessage extends LitElement {
${localize('error.troubleshooting')}.`}
.icon=${'mdi:alert-circle'}
.context=${this.message.context}
+ .dotdotdot=${this.message.dotdotdot}
>
`;
}
@@ -66,7 +80,7 @@ export class FrigateCardErrorMessage extends LitElement {
@customElement('frigate-card-progress-indicator')
export class FrigateCardProgressIndicator extends LitElement {
@property({ attribute: false })
- protected message = '';
+ public message: string | TemplateResult = '';
protected render(): TemplateResult {
return html`
@@ -83,15 +97,16 @@ export class FrigateCardProgressIndicator extends LitElement {
}
export function renderMessage(message: Message): TemplateResult {
- if (message.type == 'error') {
+ if (message.type === 'error') {
return html`
`;
- } else if (message.type == 'info') {
+ } else {
return html`
`;
}
return html``;
@@ -103,3 +118,64 @@ export function renderProgressIndicator(message?: string): TemplateResult {
`;
}
+
+/**
+ * Dispatch an event with a message to show to the user.
+ * @param element The element to send the event.
+ * @param message The message to show.
+ * @param options Optional icon and context to include.
+ */
+export function dispatchMessageEvent(
+ element: EventTarget,
+ message: string,
+ type: MessageType,
+ options?: {
+ icon?: string;
+ context?: unknown;
+ },
+): void {
+ dispatchFrigateCardEvent
(element, 'message', {
+ message: message,
+ type: type,
+ icon: options?.icon,
+ context: options?.context,
+ });
+}
+
+/**
+ * Dispatch an event with an error message to show to the user.
+ * @param element The element to send the event.
+ * @param message The message to show.
+ * @param options Optional context to include.
+ */
+export function dispatchErrorMessageEvent(
+ element: EventTarget,
+ message: string,
+ options?: {
+ context?: unknown;
+ },
+): void {
+ dispatchMessageEvent(element, message, 'error', {
+ context: options?.context,
+ });
+}
+
+/**
+ * Dispatch an event with an error message to show to the user.
+ * @param element The element to send the event.
+ * @param message The message to show.
+ */
+export function dispatchFrigateCardErrorEvent(
+ element: EventTarget,
+ error: FrigateCardError,
+): void {
+ dispatchErrorMessageEvent(element, error.message, { context: error.context });
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-progress-indicator': FrigateCardProgressIndicator;
+ 'frigate-card-error-message': FrigateCardErrorMessage;
+ 'frigate-card-message': FrigateCardMessage;
+ }
+}
diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts
index 6c37f651..5a7ccd67 100644
--- a/src/components/next-prev-control.ts
+++ b/src/components/next-prev-control.ts
@@ -5,6 +5,9 @@ import { classMap } from 'lit/directives/class-map.js';
import { NextPreviousControlConfig } from '../types.js';
import controlStyle from '../scss/next-previous-control.scss';
+import { createFetchThumbnailTask } from '../utils/thumbnail.js';
+import { HomeAssistant } from 'custom-card-helpers';
+import { renderTask } from '../utils/task.js';
@customElement('frigate-card-next-previous-control')
export class FrigateCardNextPreviousControl extends LitElement {
@@ -13,11 +16,14 @@ export class FrigateCardNextPreviousControl extends LitElement {
set controlConfig(controlConfig: NextPreviousControlConfig | undefined) {
if (controlConfig?.size) {
- this.style.setProperty('--frigate-card-next-prev-size', controlConfig.size);
+ this.style.setProperty('--frigate-card-next-prev-size', `${controlConfig.size}px`);
}
this._controlConfig = controlConfig;
}
+ @property({ attribute: false })
+ public hass?: HomeAssistant;
+
@state()
protected _controlConfig?: NextPreviousControlConfig;
@@ -31,7 +37,13 @@ export class FrigateCardNextPreviousControl extends LitElement {
public disabled = false;
// Label that is used for ARIA support and as tooltip.
- @property() label = "";
+ @property() label = '';
+
+ protected _embedThumbnailTask = createFetchThumbnailTask(
+ this,
+ () => this.hass,
+ () => this.thumbnail,
+ );
protected render(): TemplateResult {
if (this.disabled || !this._controlConfig || this._controlConfig.style == 'none') {
@@ -50,18 +62,15 @@ export class FrigateCardNextPreviousControl extends LitElement {
if (['chevrons', 'icons'].includes(this._controlConfig.style)) {
let icon: string;
if (this._controlConfig.style === 'chevrons') {
- icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
+ icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
} else {
if (!this.icon) {
return html``;
}
- icon = this.icon
+ icon = this.icon;
}
- return html`
+ return html`
`;
}
@@ -69,15 +78,30 @@ export class FrigateCardNextPreviousControl extends LitElement {
if (!this.thumbnail) {
return html``;
}
- return html`
`;
+
+ return renderTask(
+ this,
+ this._embedThumbnailTask,
+ (embeddedThumbnail: string | null) =>
+ embeddedThumbnail
+ ? html`
`
+ : html``,
+ () => html``,
+ );
}
static get styles(): CSSResultGroup {
return unsafeCSS(controlStyle);
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-next-previous-control': FrigateCardNextPreviousControl;
+ }
+}
diff --git a/src/components/submenu.ts b/src/components/submenu.ts
index 97a1d835..a1bd6247 100644
--- a/src/components/submenu.ts
+++ b/src/components/submenu.ts
@@ -1,43 +1,65 @@
-import type { Corner } from '@material/mwc-menu';
-import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { HomeAssistant } from 'custom-card-helpers';
-import { customElement, property } from 'lit/decorators.js';
import {
- frigateCardHasAction,
- refreshDynamicStateParameters,
- stopEventFromActivatingCardWideActions,
-} from '../common.js';
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
+import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
-
-import { ExtendedHomeAssistant, MenuSubmenu, MenuSubmenuItem } from '../types.js';
import { actionHandler } from '../action-handler-directive.js';
-
import submenuStyle from '../scss/submenu.scss';
+import {
+ MenuSubmenu,
+ MenuSubmenuItem,
+ MenuSubmenuSelect,
+ StateParameters,
+} from '../types.js';
+import {
+ frigateCardHasAction,
+ stopEventFromActivatingCardWideActions,
+} from '../utils/action.js';
+import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha';
+import { domainIcon } from '../utils/icons/domain-icon.js';
@customElement('frigate-card-submenu')
export class FrigateCardSubmenu extends LitElement {
@property({ attribute: false })
- public hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: HomeAssistant;
@property({ attribute: false })
public submenu?: MenuSubmenu;
- @property({ attribute: false })
- public corner?: Corner;
-
protected _renderItem(item: MenuSubmenuItem): TemplateResult | void {
if (!this.hass) {
return;
}
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item });
+ const getIcon = (stateParameters: StateParameters): TemplateResult => {
+ if (stateParameters.icon) {
+ return html`
+ `;
+ }
+ return html``;
+ };
return html`
{
// Attach the action config so ascendants have access to it.
@@ -48,17 +70,9 @@ export class FrigateCardSubmenu extends LitElement {
hasDoubleClick: frigateCardHasAction(item.double_tap_action),
})}
>
- ${stateParameters.title || ''}
- ${stateParameters.icon
- ? html`
- `
- : ``}
+ ${stateParameters.title || ''}
+ ${item.subtitle ? html`${item.subtitle}` : ''}
+ ${getIcon(stateParameters)}
`;
}
@@ -69,7 +83,7 @@ export class FrigateCardSubmenu extends LitElement {
}
return html`
`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-submenu': FrigateCardSubmenu;
+ 'frigate-card-submenu-select': FrigateCardSubmenuSelect;
+ }
+}
diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts
new file mode 100644
index 00000000..2b6978a8
--- /dev/null
+++ b/src/components/surround-thumbnails.ts
@@ -0,0 +1,194 @@
+import {
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import surroundThumbnailsStyle from '../scss/surround.scss';
+import {
+ BrowseMediaQueryParameters,
+ CameraConfig,
+ ExtendedHomeAssistant,
+ FrigateBrowseMediaSource,
+ FrigateCardError,
+ FrigateCardView,
+ ThumbnailsControlConfig,
+} from '../types.js';
+import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
+import {
+ getFirstTrueMediaChildIndex,
+ multipleBrowseMediaQueryMerged,
+} from '../utils/ha/browse-media';
+import { View } from '../view.js';
+import { dispatchFrigateCardErrorEvent } from './message.js';
+import './surround.js';
+import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
+
+interface ThumbnailViewContext {
+ // Whetherr or not to fetch thumbnails.
+ fetch?: boolean;
+}
+
+declare module 'view' {
+ interface ViewContext {
+ thumbnails?: ThumbnailViewContext;
+ }
+}
+
+@customElement('frigate-card-surround-thumbnails')
+export class FrigateCardSurround extends LitElement {
+ @property({ attribute: false })
+ public hass?: ExtendedHomeAssistant;
+
+ @property({ attribute: false })
+ public view?: Readonly;
+
+ @property({ attribute: false, hasChanged: contentsChanged })
+ public config?: ThumbnailsControlConfig;
+
+ @property({ attribute: false })
+ public targetView?: FrigateCardView;
+
+ @property({ attribute: true, type: Boolean })
+ public fetch?: boolean;
+
+ @property({ attribute: false, hasChanged: contentsChanged })
+ public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
+
+ @property({ attribute: false })
+ public cameras?: Map;
+
+ /**
+ * 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 {
+ if (
+ !this.fetch ||
+ !this.hass ||
+ !this.view ||
+ !this.config ||
+ this.config.mode === 'none' ||
+ this.view.target ||
+ !this.browseMediaParams ||
+ !(this.view.context?.thumbnails?.fetch ?? true)
+ ) {
+ return;
+ }
+ let parent: FrigateBrowseMediaSource | null;
+ try {
+ parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
+ } catch (e) {
+ return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
+ }
+ if (getFirstTrueMediaChildIndex(parent) !== null) {
+ this.view
+ ?.evolve({
+ ...(this.targetView && { view: this.targetView }),
+ target: parent,
+ childIndex: null,
+
+ // Don't carry over history of this 'empty' view.
+ previous: null,
+ })
+ .dispatchChangeEvent(this);
+ }
+ }
+
+ /**
+ * Determine if a drawer is being used.
+ * @returns `true` if a drawer is used, `false` otherwise.
+ */
+ protected _hasDrawer(): boolean {
+ return !!this.config && ['left', 'right'].includes(this.config.mode);
+ }
+
+ /**
+ * Called before each update.
+ */
+ protected willUpdate(changedProperties: PropertyValues): void {
+ // 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', 'targetView', 'fetch', 'browseMediaParams'].some((prop) =>
+ changedProperties.has(prop),
+ )
+ ) {
+ this._fetchMedia();
+ }
+ }
+
+ /**
+ * Master render method.
+ * @returns A rendered template.
+ */
+ protected render(): TemplateResult | void {
+ if (!this.hass || !this.view || !this.config) {
+ return;
+ }
+
+ const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => {
+ // The event catch/re-dispatch below protect encapsulation: Catches the
+ // request to view thumbnails and re-dispatches a request to open the drawer
+ // (if the thumbnails are in a drawer). The new event needs to be dispatched
+ // from the origin of the inbound event, so it can be handled by
+ // .
+ if (this.config && this._hasDrawer()) {
+ dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
+ drawer: this.config.mode,
+ });
+ }
+ };
+
+ return html` changeDrawer(ev, 'open')}
+ @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
+ >
+ ${this.config && this.config.mode !== 'none'
+ ? html` changeDrawer(ev, 'close')}
+ @frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent) => {
+ // Send the view change from the source of the tap event, so the
+ // view change will be caught by the handler above (to close the drawer).
+ this.view
+ ?.evolve({
+ view: this.targetView || 'media',
+ target: ev.detail.target,
+ childIndex: ev.detail.childIndex,
+ context: null,
+ })
+ .dispatchChangeEvent(ev.composedPath()[0]);
+ }}
+ >
+ `
+ : ''}
+
+ `;
+ }
+
+ /**
+ * Return compiled CSS styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(surroundThumbnailsStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-surround-thumbnails': FrigateCardSurround;
+ }
+}
diff --git a/src/components/surround.ts b/src/components/surround.ts
new file mode 100644
index 00000000..414058ab
--- /dev/null
+++ b/src/components/surround.ts
@@ -0,0 +1,77 @@
+import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
+import { createRef, ref, Ref } from 'lit/directives/ref.js';
+import { customElement } from 'lit/decorators.js';
+
+import { FrigateCardDrawer } from './drawer.js';
+
+import './drawer.js';
+
+import surroundStyle from '../scss/surround.scss';
+
+interface FrigateCardDrawerOpen {
+ drawer: 'left' | 'right';
+}
+
+@customElement('frigate-card-surround')
+export class FrigateCardSurround extends LitElement {
+ protected _refDrawerLeft: Ref = createRef();
+ protected _refDrawerRight: Ref = createRef();
+ protected _boundDrawerHandler = this._drawerHandler.bind(this);
+
+ /**
+ * Component connected callback.
+ */
+ connectedCallback(): void {
+ super.connectedCallback();
+ this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
+ this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
+ }
+
+ /**
+ * Component disconnected callback.
+ */
+ disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
+ this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
+ }
+
+ protected _drawerHandler(ev: Event) {
+ const drawer = (ev as CustomEvent).detail.drawer;
+ const open = ev.type.endsWith(':open');
+ if (drawer === 'left' && this._refDrawerLeft.value) {
+ this._refDrawerLeft.value.open = open;
+ } else if (drawer === 'right' && this._refDrawerRight.value) {
+ this._refDrawerRight.value.open = open;
+ }
+ }
+
+ /**
+ * Master render method.
+ * @returns A rendered template.
+ */
+ protected render(): TemplateResult | void {
+ return html`
+
+
+
+
+
+
+
+ `;
+ }
+
+ /**
+ * Return compiled CSS styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(surroundStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "frigate-card-surround": FrigateCardSurround
+ }
+}
diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts
index 3b109deb..9136a54d 100644
--- a/src/components/thumbnail-carousel.ts
+++ b/src/components/thumbnail-carousel.ts
@@ -1,46 +1,113 @@
-import { BrowseMediaUtil } from '../browse-media-util.js';
-import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit';
-import { EmblaOptionsType } from 'embla-carousel';
-import { customElement, property } from 'lit/decorators.js';
-
-import type { BrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
-import { FrigateCardCarousel } from './carousel.js';
-import { dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions } from '../common.js';
-
+import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
+import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
+import {
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { classMap } from 'lit/directives/class-map.js';
+import { createRef, ref, Ref } from 'lit/directives/ref.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
+import {
+ CameraConfig,
+ ExtendedHomeAssistant,
+ FrigateBrowseMediaSource,
+ ThumbnailsControlConfig,
+} from '../types.js';
+import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
+import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
+import { isTrueMedia } from '../utils/ha/browse-media';
+import { View } from '../view.js';
+import { FrigateCardCarousel } from './carousel.js';
+import './thumbnail.js';
+import './carousel.js';
+import { ifDefined } from 'lit/directives/if-defined.js';
export interface ThumbnailCarouselTap {
slideIndex: number;
- target: BrowseMediaSource;
+ target: FrigateBrowseMediaSource;
childIndex: number;
}
@customElement('frigate-card-thumbnail-carousel')
-export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
+export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
- protected target?: BrowseMediaSource;
-
- protected _tapSelected?;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- set config(config: ThumbnailsControlConfig | undefined) {
- if (config) {
- if (config && config.size !== undefined && config.size !== null) {
- this.style.setProperty('--frigate-card-carousel-thumbnail-size', config.size);
- }
- this._config = config;
- }
+ public view?: Readonly;
+
+ // Use contentsChanged here to avoid the carousel rebuilding and resetting in
+ // front of the user, unless the contents have actually changed.
+ @property({ attribute: false, hasChanged: contentsChanged })
+ public target?: FrigateBrowseMediaSource | null;
+
+ @property({ attribute: false })
+ public cameras?: Map;
+
+ protected _refCarousel: Ref = createRef();
+
+ // Thumbnail carousels can expand (e.g. drawer-based carousels after the main
+ // media loads). The carousel must be re-initialized in these cases, or the
+ // dynamic sizing fails (and users can scroll past the end of the carousel).
+ protected _resizeObserver: ResizeObserver;
+
+ @property({ attribute: false })
+ public config?: ThumbnailsControlConfig;
+
+ @state()
+ protected _selected: number | null = null;
+
+ protected _carouselOptions?: EmblaOptionsType;
+ protected _carouselPlugins: EmblaPluginType[] = [
+ WheelGesturesPlugin({
+ // Whether the carousel is vertical or horizontal, interpret y-axis wheel
+ // gestures as scrolling for the carousel.
+ forceWheelAxis: 'y',
+ }),
+ ];
+
+ constructor() {
+ super();
+ this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
}
- protected _config?: ThumbnailsControlConfig;
@property({ attribute: false })
- set highlightSelected(value: boolean) {
+ set selected(selected: number | null) {
+ this._selected = selected;
this.style.setProperty(
'--frigate-card-carousel-thumbnail-opacity',
- value ? '0.6' : '1.0',
+ selected === null ? '1.0' : '0.4',
);
}
+ /**
+ * Handle gallery resize.
+ */
+ protected _resizeHandler(): void {
+ this._refCarousel.value?.carouselReInitWhenSafe();
+ }
+
+ /**
+ * Component connected callback.
+ */
+ connectedCallback(): void {
+ super.connectedCallback();
+ this._resizeObserver.observe(this);
+ }
+
+ /**
+ * Component disconnected callback.
+ */
+ disconnectedCallback(): void {
+ this._resizeObserver.disconnect();
+ super.disconnectedCallback();
+ }
+
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
@@ -49,28 +116,9 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
return {
containScroll: 'keepSnaps',
dragFree: true,
+ startIndex: this._selected ?? 0,
};
}
-
- /**
- * Scroll to a particular slide.
- * @param index Slide number.
- */
- carouselScrollTo(index: number): void {
- if (!this._carousel) {
- return;
- }
-
- if (this._tapSelected !== undefined) {
- this._carousel.slideNodes()[this._tapSelected].classList.remove('slide-selected');
- }
-
- super.carouselScrollTo(index);
-
- this._carousel.slideNodes()[index].classList.add('slide-selected');
- this._tapSelected = index;
- }
-
/**
* Get slides to include in the render.
* @returns The slides to include in the render.
@@ -90,44 +138,111 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
return slides;
}
+ /**
+ * Called when an update will occur.
+ * @param changedProps The changed properties
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('config')) {
+ if (this.config?.size) {
+ this.style.setProperty('--frigate-card-thumbnail-size', `${this.config.size}px`);
+ }
+ const direction = this._getDirection();
+ if (direction) {
+ this.setAttribute('direction', direction);
+ } else {
+ this.removeAttribute('direction');
+ }
+ }
+
+ if (!this._carouselOptions) {
+ // Want to set the initial carousel options just before the first render
+ // in order to get the startIndex correct in the options. It is not safe
+ // to rely on carouselScrollTo() post update, since the nested carousel
+ // may not yet be actual rendered/created.
+ this._carouselOptions = this._getOptions();
+ }
+ }
+
+ /**
+ * The updated lifecycle callback for this element.
+ * @param changedProperties The properties that were changed in this render.
+ */
+ updated(changedProperties: PropertyValues): void {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('_selected')) {
+ this.updateComplete.then(() => {
+ if (this._selected !== null) {
+ this._refCarousel.value?.carouselScrollTo(this._selected);
+ }
+ });
+ }
+ }
+
/**
* Render a given thumbnail.
* @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered.
*/
protected _renderThumbnail(
- parent: BrowseMediaSource,
+ parent: FrigateBrowseMediaSource,
childIndex: number,
slideIndex: number,
): TemplateResult | void {
- if (!parent.children || !parent.children.length) {
+ if (
+ !parent.children ||
+ !parent.children.length ||
+ !isTrueMedia(parent.children[childIndex])
+ ) {
return;
}
- const mediaToRender = parent.children[childIndex];
- if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) {
- return;
- }
+ const classes = {
+ embla__slide: true,
+ 'slide-selected': this._selected === childIndex,
+ };
- return html` {
- if (this._carousel && this._carousel.clickAllowed()) {
- dispatchFrigateCardEvent
(this, 'carousel:tap', {
- slideIndex: slideIndex,
- target: parent,
- childIndex: childIndex,
- });
+ if (this._refCarousel.value?.carouselClickAllowed()) {
+ dispatchFrigateCardEvent(
+ this,
+ 'thumbnail-carousel:tap',
+ {
+ slideIndex: slideIndex,
+ target: parent,
+ childIndex: childIndex,
+ },
+ );
}
stopEventFromActivatingCardWideActions(ev);
}}
>
-
- `;
+ `;
+ }
+
+ /**
+ * Get the direction of the thumbnail carousel.
+ * @returns `vertical`, `horizontal` or undefined.
+ */
+ protected _getDirection(): 'horizontal' | 'vertical' | undefined {
+ if (this.config?.mode === 'left' || this.config?.mode === 'right') {
+ return 'vertical';
+ } else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
+ return 'horizontal';
+ }
+ return undefined;
}
/**
@@ -136,21 +251,30 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
*/
protected render(): TemplateResult | void {
const slides = this._getSlides();
- if (!slides || !this._config || this._config.mode == 'none') {
+ if (!slides.length || !this.config || this.config.mode === 'none') {
return;
}
- return html` `;
+ return html`
+ ${slides}
+ `;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
- return [super.styles, unsafeCSS(thumbnailCarouselStyle)];
+ return unsafeCSS(thumbnailCarouselStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-thumbnail-carousel': FrigateCardThumbnailCarousel;
}
}
diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts
new file mode 100644
index 00000000..30450b60
--- /dev/null
+++ b/src/components/thumbnail.ts
@@ -0,0 +1,326 @@
+import { format, fromUnixTime } from 'date-fns';
+import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { classMap } from 'lit/directives/class-map.js';
+import { localize } from '../localize/localize.js';
+import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
+import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
+import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
+import thumbnailStyle from '../scss/thumbnail.scss';
+import type {
+ ExtendedHomeAssistant,
+ FrigateBrowseMediaSource,
+ FrigateEvent,
+ FrigateRecording,
+} from '../types.js';
+import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
+import { errorToConsole, prettifyTitle } from '../utils/basic.js';
+import { retainEvent } from '../utils/frigate.js';
+import { getEventDurationString } from '../utils/ha/browse-media.js';
+import { renderTask } from '../utils/task.js';
+import { createFetchThumbnailTask } from '../utils/thumbnail.js';
+import { View } from '../view.js';
+
+// The minimum width of a thumbnail with details enabled.
+export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
+
+@customElement('frigate-card-thumbnail-feature-event')
+export class FrigateCardThumbnailFeatureEvent extends LitElement {
+ @property({ attribute: false })
+ public thumbnail?: string;
+
+ @property({ attribute: false })
+ public hass?: ExtendedHomeAssistant;
+
+ protected _embedThumbnailTask = createFetchThumbnailTask(
+ this,
+ () => this.hass,
+ () => this.thumbnail,
+ );
+
+ protected render(): TemplateResult | void {
+ return html`
+ ${this.thumbnail
+ ? renderTask(
+ this,
+ this._embedThumbnailTask,
+ (embeddedThumbnail: string | null) =>
+ embeddedThumbnail
+ ? html`
`
+ : html``
+ )
+ : html` `}
+ `;
+ }
+
+ static get styles(): CSSResult {
+ return unsafeCSS(thumbnailFeatureEventStyle);
+ }
+}
+
+@customElement('frigate-card-thumbnail-feature-recording')
+export class FrigateCardThumbnailFeatureRecording extends LitElement {
+ @property({ attribute: false })
+ public date?: Date;
+
+ protected render(): TemplateResult | void {
+ if (!this.date) {
+ return;
+ }
+ return html`
+ ${format(this.date, 'HH:mm')}
+ ${format(this.date, 'MMM do')}
+ `;
+ }
+
+ static get styles(): CSSResult {
+ return unsafeCSS(thumbnailFeatureRecordingStyle);
+ }
+}
+
+@customElement('frigate-card-thumbnail-details-event')
+export class FrigateCardThumbnailDetailsEvent extends LitElement {
+ @property({ attribute: false })
+ public event?: FrigateEvent;
+
+ protected render(): TemplateResult | void {
+ if (!this.event) {
+ return;
+ }
+ const score = (this.event.top_score * 100).toFixed(2) + '%';
+ return html`
+
${prettifyTitle(this.event.label)}
+
+ ${localize('event.start')}:
+ ${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')}
+
+
+ ${localize('event.duration')}:
+ ${getEventDurationString(this.event)}
+
+
+
+ ${score}
+
`;
+ }
+
+ static get styles(): CSSResult {
+ return unsafeCSS(thumbnailDetailsStyle);
+ }
+}
+
+@customElement('frigate-card-thumbnail-details-recording')
+export class FrigateCardThumbnailDetailsRecording extends LitElement {
+ @property({ attribute: false })
+ public recording?: FrigateRecording;
+
+ protected render(): TemplateResult | void {
+ if (!this.recording) {
+ return;
+ }
+ return html`
+
${prettifyTitle(this.recording.camera) || ''}
+ ${this.recording.seek_time
+ ? html`
+ ${localize('recording.seek')}
+ ${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')}
+
`
+ : html``}
+
+
+ ${this.recording.events}
+ ${localize('recording.events')}
+
`;
+ }
+
+ static get styles(): CSSResult {
+ return unsafeCSS(thumbnailDetailsStyle);
+ }
+}
+
+@customElement('frigate-card-thumbnail')
+export class FrigateCardThumbnail extends LitElement {
+ @property({ attribute: true, type: Boolean })
+ public details = false;
+
+ @property({ attribute: true, type: Boolean })
+ public show_favorite_control = false;
+
+ @property({ attribute: true, type: Boolean })
+ public show_timeline_control = false;
+
+ // ======================
+ // Target-based interface
+ // ======================
+ @property({ attribute: false })
+ public target?: FrigateBrowseMediaSource | null;
+
+ @property({ attribute: false })
+ public childIndex?: number;
+
+ // ===================================================
+ // Raw interface (can override target-based interface)
+ // ===================================================
+ @property({ attribute: true })
+ public thumbnail?: string;
+
+ @property({ attribute: true })
+ public label?: string;
+
+ @property({ attribute: false })
+ public event?: FrigateEvent;
+
+ // ================================
+ // Optional parameters for controls
+ // ================================
+ @property({ attribute: false })
+ public view?: Readonly;
+
+ @property({ attribute: false })
+ public hass?: ExtendedHomeAssistant;
+
+ @property({ attribute: false })
+ public clientID?: string;
+
+ /**
+ * Render the element.
+ * @returns A template to display to the user.
+ */
+ protected render(): TemplateResult | void {
+ let event: FrigateEvent | null = null;
+ let recording: FrigateRecording | null = null;
+ let thumbnail: string | null = null;
+ let label: string | null = null;
+
+ // Take the event / thumbnail / label from the data-bound media (if specified).
+ if (this.target && this.target.children && this.childIndex !== undefined) {
+ const media = this.target.children[this.childIndex];
+ event = media.frigate?.event ?? null;
+ recording = media.frigate?.recording ?? null;
+ thumbnail = media.thumbnail;
+ label = media.title;
+ }
+
+ // Always give the overrides preference (if specified).
+ if (this.event) {
+ event = this.event;
+ }
+ thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
+ label = this.label ? this.label : label;
+
+ if (!event && !recording) {
+ return;
+ }
+
+ const starClasses = {
+ star: true,
+ starred: !!event?.retain_indefinitely,
+ };
+
+ return html` ${event
+ ? html``
+ : html``}
+ ${this.show_favorite_control && event && this.hass && this.clientID
+ ? html` {
+ stopEventFromActivatingCardWideActions(ev);
+ if (event && this.hass && this.clientID) {
+ retainEvent(
+ this.hass,
+ this.clientID,
+ event.id,
+ !event.retain_indefinitely,
+ )
+ .then(() => {
+ if (event) {
+ event.retain_indefinitely = !event.retain_indefinitely;
+ this.requestUpdate();
+ }
+ })
+ .catch((e) => {
+ errorToConsole(e);
+ });
+ }
+ }}
+ />`
+ : ``}
+ ${this.details && event
+ ? html``
+ : this.details && recording
+ ? html``
+ : html``}
+ ${this.show_timeline_control
+ ? html` {
+ stopEventFromActivatingCardWideActions(ev);
+ if (event) {
+ this.view
+ ?.evolve({
+ view: 'timeline',
+ target: this.target,
+ childIndex: this.childIndex ?? null,
+ })
+ .removeContext('timeline')
+ .dispatchChangeEvent(this);
+ } else if (recording) {
+ this.view
+ ?.evolve({
+ view: 'timeline',
+ target: null,
+ childIndex: null,
+ })
+ .mergeInContext({
+ timeline: {
+ window: {
+ start: fromUnixTime(recording.start_time),
+ end: fromUnixTime(recording.end_time),
+ },
+ },
+ })
+ .dispatchChangeEvent(this);
+ }
+ }}
+ >`
+ : ''}`;
+ }
+
+ /**
+ * Get element styles.
+ */
+ static get styles(): CSSResult {
+ return unsafeCSS(thumbnailStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-thumbnail': FrigateCardThumbnail;
+ 'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording;
+ 'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent;
+ 'frigate-card-thumbnail-feature-recording': FrigateCardThumbnailFeatureRecording;
+ 'frigate-card-thumbnail-feature-event': FrigateCardThumbnailFeatureEvent;
+ }
+}
diff --git a/src/components/timeline.ts b/src/components/timeline.ts
new file mode 100644
index 00000000..ca009e4e
--- /dev/null
+++ b/src/components/timeline.ts
@@ -0,0 +1,1347 @@
+import { HomeAssistant } from 'custom-card-helpers';
+import {
+ add,
+ differenceInSeconds,
+ endOfHour,
+ format,
+ fromUnixTime,
+ getUnixTime,
+ startOfHour,
+ sub,
+} from 'date-fns';
+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 { createRef, ref, Ref } from 'lit/directives/ref.js';
+import { isEqual } from 'lodash-es';
+import { ViewContext } from 'view';
+import { DataSet } from 'vis-data/esnext';
+import {
+ DataGroupCollectionType,
+ Timeline,
+ TimelineEventPropertiesResult,
+ TimelineItem,
+ TimelineOptions,
+ TimelineOptionsCluster,
+ TimelineWindow,
+} from 'vis-timeline/esnext';
+import { CAMERA_BIRDSEYE } from '../const';
+import { localize } from '../localize/localize';
+import timelineCoreStyle from '../scss/timeline-core.scss';
+import timelineStyle from '../scss/timeline.scss';
+import {
+ BrowseMediaQueryParameters,
+ CameraConfig,
+ ExtendedHomeAssistant,
+ FrigateBrowseMediaSource,
+ frigateCardConfigDefaults,
+ FrigateCardError,
+ FrigateEvent,
+ TimelineConfig,
+} from '../types';
+import { stopEventFromActivatingCardWideActions } from '../utils/action';
+import { dispatchFrigateCardEvent, errorToConsole, isHoverableDevice, prettifyTitle } from '../utils/basic';
+import { getCameraTitle } from '../utils/camera.js';
+import {
+ getRecordingSegments,
+ getRecordingsSummary,
+ getUniqueFrigateCameraEventsID,
+ getUniqueFrigateCameraID,
+ RecordingSegments,
+ RecordingSummary,
+} from '../utils/frigate';
+import {
+ createEventParentForChildren,
+ createVideoChild,
+ generateRecordingIdentifier,
+ getBrowseMediaQueryParameters,
+ isTrueMedia,
+ multipleBrowseMediaQuery,
+} from '../utils/ha/browse-media';
+import { View } from '../view';
+import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js';
+import './surround-thumbnails.js';
+
+const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
+
+interface FrigateCardGroupData {
+ id: string;
+ content: string;
+}
+interface FrigateCardTimelineItem extends TimelineItem {
+ start: number;
+ end?: number;
+ event?: FrigateEvent;
+ source?: FrigateBrowseMediaSource;
+}
+
+interface TimelineViewContext {
+ // The selected timeline window.
+ window?: TimelineWindow;
+
+ // The date of the last event fetch.
+ dateFetch?: Date;
+}
+
+declare module 'view' {
+ interface ViewContext {
+ timeline?: TimelineViewContext;
+ }
+}
+
+type TimelineMediaType = 'all' | 'clips' | 'snapshots';
+
+interface CameraRecordings {
+ segments: RecordingSegments;
+ summary: RecordingSummary;
+}
+
+// An event used to fetch the HASS object. See "Special note" below.
+class HASSRequestEvent extends Event {
+ public hass?: ExtendedHomeAssistant;
+}
+
+/**
+ * A manager to maintain/fetch timeline events.
+ */
+class TimelineDataManager {
+ protected _dataset = new DataSet();
+
+ // The earliest date managed.
+ protected _dateStart?: Date;
+
+ // The latest date managed.
+ protected _dateEnd?: Date;
+
+ // The last fetch date.
+ protected _dateFetch?: Date;
+
+ // The maximum allowable age of fetch data (will not fetch more frequently
+ // than this).
+ protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
+
+ // Get the last event fetch date.
+ get lastFetchDate(): Date | null {
+ return this._dateFetch ?? null;
+ }
+
+ /**
+ * Retrieve the underlying dataset.
+ */
+ get dataset(): DataSet {
+ return this._dataset;
+ }
+
+ /**
+ * Determine if the dataset is empty.
+ * @returns
+ */
+ public isEmpty(): boolean {
+ return this._dataset.length === 0;
+ }
+
+ /**
+ * Clear the dataset.
+ */
+ public clear(): void {
+ this._dataset.clear();
+ }
+
+ /**
+ * Add a FrigateBrowseMediaSource object to the managed timeline.
+ * @param camera The id the camera this object is from.
+ * @param target The FrigateBrowseMediaSource to add.
+ */
+ protected _addMediaSource(
+ camera: string,
+ mediaPriority: TimelineMediaType,
+ target: FrigateBrowseMediaSource,
+ ): void {
+ const items: FrigateCardTimelineItem[] = [];
+ target.children?.forEach((child) => {
+ const event = child.frigate?.event;
+ if (
+ event &&
+ isTrueMedia(child) &&
+ ['video', 'image'].includes(child.media_content_type)
+ ) {
+ let item = this._dataset.get(event.id);
+ if (!item) {
+ item = {
+ id: event.id,
+ group: camera,
+ content: '',
+ start: event.start_time * 1000,
+ event: event,
+ };
+ }
+ if (
+ (child.media_content_type === 'video' &&
+ ['all', 'clips'].includes(mediaPriority)) ||
+ (!item.source &&
+ child.media_content_type === 'image' &&
+ ['all', 'snapshots'].includes(mediaPriority))
+ ) {
+ item.source = child;
+ }
+ if (event.end_time) {
+ item['end'] = event.end_time * 1000;
+ item['type'] = 'range';
+ } else {
+ item['type'] = 'point';
+ }
+ items.push(item);
+ }
+ });
+ this._dataset.update(items);
+ }
+
+ /**
+ * Determine if the timeline has coverage for a given range of dates.
+ * @param start The start of the date range.
+ * @param end An optional end of the date range.
+ * @returns
+ */
+ public hasCoverage(now: Date, start: Date, end?: Date): boolean {
+ // Never fetched: no coverage.
+ if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
+ return false;
+ }
+
+ // If the most recent fetch is older than maxAgeSeconds: no coverage.
+ if (
+ this._maxAgeSeconds &&
+ now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
+ ) {
+ return false;
+ }
+
+ // If the most requested data is earlier than the earliest stored: no
+ // coverage.
+ if (start < this._dateStart) {
+ return false;
+ }
+
+ // If there's no end time specified: there IS coverage.
+ if (!end) {
+ return true;
+ }
+ // If the requested end time is older than the oldest requested: there IS
+ // coverage.
+ if (end.getTime() < this._dateEnd.getTime()) {
+ return true;
+ }
+ // If there's no maxAgeSeconds specified: no coverage.
+ if (!this._maxAgeSeconds) {
+ return false;
+ }
+ // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
+ if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
+ return false;
+ }
+
+ // End time is within `_maxAgeSeconds` of the latest data: there IS
+ // coverage.
+ return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime();
+ }
+
+ /**
+ * Fetch events if no coverage in given range.
+ * @param element The element to send error events from.
+ * @param hass The HomeAssistant object.
+ * @param cameras The cameras map.
+ * @param start Fetch events that start later than this date.
+ * @param end Fetch events that start earlier than this date.
+ * @returns `true` if events were fetched, `false` otherwise.
+ */
+ public async fetchIfNecessary(
+ element: HTMLElement,
+ hass: ExtendedHomeAssistant,
+ cameras: Map,
+ eventMedia: TimelineMediaType,
+ start: Date,
+ end: Date,
+ recordings?: boolean,
+ ): Promise {
+ // Cannot fetch the future, always clip the end date to now so as to avoid
+ // checking for coverage that could not possibly exist yet.
+ const now = new Date();
+ end = end > now ? now : end;
+
+ if (this.hasCoverage(now, start, end)) {
+ return false;
+ }
+
+ if (!this._dateStart || start < this._dateStart) {
+ this._dateStart = start;
+ }
+ if (!this._dateEnd || end > this._dateEnd) {
+ this._dateEnd = end;
+ }
+ this._dateFetch = new Date();
+
+ await Promise.all([
+ // Events are always fetched for the maximum extent of the managed
+ // range. This is because events may change at any point in time
+ // (e.g. a long-running event that ends).
+ this._fetchEvents(
+ element,
+ hass,
+ cameras,
+ eventMedia,
+ this._dateStart,
+ this._dateEnd,
+ ),
+ ...(recordings ? [this._fetchRecordings(hass, cameras)] : []),
+ ]);
+
+ return true;
+ }
+
+ /**
+ * Fetch recording hours for the timeline.
+ * @param element The element to send error events from.
+ * @param hass The HomeAssistant object.
+ * @param cameras The cameras map.
+ * @param start Fetch events that start later than this date.
+ * @param end Fetch events that start earlier than this date.
+ */
+ protected async _fetchRecordings(
+ hass: ExtendedHomeAssistant,
+ cameras: Map,
+ ): Promise {
+ const items: FrigateCardTimelineItem[] = [];
+ const now = new Date();
+
+ const storeRecordings = async (
+ camera: string,
+ config: CameraConfig,
+ ): Promise => {
+ if (!config.frigate.camera_name) {
+ return;
+ }
+ let summary: RecordingSummary = [];
+ try {
+ summary = await getRecordingsSummary(
+ hass,
+ config.frigate.client_id,
+ config.frigate.camera_name,
+ );
+ } catch (e) {
+ // Recording failure should not disrupt the rest of the timeline
+ // experience.
+ errorToConsole(e as Error);
+ }
+
+ for (const dayData of summary) {
+ for (const hourData of dayData.hours) {
+ const hour = add(dayData.day, { hours: hourData.hour });
+ const endHour = endOfHour(hour);
+ items.push({
+ id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`,
+ group: camera,
+ start: getUnixTime(startOfHour(hour)) * 1000,
+
+ // Don't let the recordings show off into the future (even though it
+ // is intended to be indicative of any recordings within that hour
+ // -- it still looks strange!)
+ end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000,
+ type: 'background',
+ content: '',
+ });
+ }
+ }
+ };
+
+ await Promise.all(
+ Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) =>
+ storeRecordings(camera, config),
+ ),
+ );
+
+ this._dataset.update(items);
+ }
+
+ /**
+ * Fetch events for the timeline.
+ * @param element The element to send error events from.
+ * @param hass The HomeAssistant object.
+ * @param cameras The cameras map.
+ * @param start Fetch events that start later than this date.
+ * @param end Fetch events that start earlier than this date.
+ */
+ protected async _fetchEvents(
+ element: HTMLElement,
+ hass: HomeAssistant,
+ cameras: Map,
+ media: TimelineMediaType,
+ start: Date,
+ end: Date,
+ ): Promise {
+ const params: BrowseMediaQueryParameters[] = [];
+ cameras.forEach((cameraConfig, cameraID) => {
+ (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
+ if (cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE) {
+ const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
+ before: end.getTime() / 1000,
+ after: start.getTime() / 1000,
+ unlimited: true,
+ mediaType: mediaType as 'clips' | 'snapshots',
+ });
+ if (param) {
+ params.push(param);
+ }
+ }
+ });
+ });
+
+ if (!params.length) {
+ return;
+ }
+
+ let results: Map;
+ try {
+ results = await multipleBrowseMediaQuery(hass, params);
+ } catch (e) {
+ return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
+ }
+
+ for (const [query, result] of results.entries()) {
+ if (query.cameraID) {
+ this._addMediaSource(query.cameraID, media, result);
+ }
+ }
+ }
+}
+
+/**
+ * A simgple thumbnail wrapper class for use in the timeline where LIT data
+ * bindings are not available.
+ */
+@customElement('frigate-card-timeline-thumbnail')
+export class FrigateCardTimelineThumbnail extends LitElement {
+ @property({ attribute: true })
+ public thumbnail?: string;
+
+ @property({ attribute: true, type: Boolean })
+ public details = false;
+
+ @property({ attribute: true })
+ public event?: string;
+
+ @property({ attribute: true })
+ public label?: string;
+
+ /**
+ * Master render method.
+ * @returns A rendered template.
+ */
+ protected render(): TemplateResult | void {
+ // Don't display tooltips on touch devices, they just get in the way of
+ // the drawer.
+ if (!this.thumbnail || !this.event) {
+ return html``;
+ }
+
+ /* Special note on what's going on here:
+ *
+ * This component does not have access to HASS, as there's no way to pass it
+ * in via the string-based tooltip that timeline supports. Instead dispatch
+ * an event to request HASS which the timeline adds to the event object
+ * before execution continues.
+ */
+ const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, {
+ composed: true,
+ bubbles: true,
+ });
+ this.dispatchEvent(hassRequest);
+ if (!hassRequest.hass) {
+ return html``;
+ }
+
+ return html`
+ `;
+ }
+}
+
+@customElement('frigate-card-timeline')
+export class FrigateCardTimeline extends LitElement {
+ @property({ attribute: false })
+ public hass?: ExtendedHomeAssistant;
+
+ @property({ attribute: false })
+ public view?: Readonly;
+
+ @property({ attribute: false })
+ public cameras?: Map;
+
+ @property({ attribute: false })
+ public timelineConfig?: TimelineConfig;
+
+ /**
+ * Master render method.
+ * @returns A rendered template.
+ */
+ protected render(): TemplateResult | void {
+ if (!this.timelineConfig) {
+ return html``;
+ }
+
+ return html`
+
+
+ `;
+ }
+
+ /**
+ * Return compiled CSS styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(timelineStyle);
+ }
+}
+
+@customElement('frigate-card-timeline-core')
+export class FrigateCardTimelineCore extends LitElement {
+ @property({ attribute: false })
+ public hass?: ExtendedHomeAssistant;
+
+ @property({ attribute: false })
+ public view?: Readonly;
+
+ @property({ attribute: false })
+ public cameras?: Map;
+
+ @property({ attribute: false })
+ public timelineConfig?: TimelineConfig;
+
+ protected _data = new TimelineDataManager();
+
+ protected _refTimeline: Ref = createRef();
+ protected _timeline?: Timeline;
+
+ // Need a way to separate when a user clicks (to pan the timeline) vs when a
+ // user clicks (to choose a recording (non-event) to play).
+ protected _pointerHeld = false;
+ protected _ignoreClick = false;
+
+ protected readonly _isHoverableDevice = isHoverableDevice();
+
+ /**
+ * Get a tooltip for a given timeline event.
+ * @param source The FrigateBrowseMediaSource in question.
+ * @returns The tooltip as a string to render.
+ */
+ protected _getTooltip(item: TimelineItem): string {
+ const source = (item).source;
+ if (!this._isHoverableDevice || !source) {
+ // Don't display tooltips on touch devices, they just get in the way of
+ // the drawer.
+ return '';
+ }
+
+ const eventAttr = source.frigate?.event
+ ? `event='${JSON.stringify(source.frigate.event)}'`
+ : '';
+ const detailsAttr = this.timelineConfig?.controls.thumbnails.show_details
+ ? 'details'
+ : '';
+
+ // Cannot use Lit data-bindings as visjs requires a string for tooltips.
+ // Note that changes to attributes here must be mirrored in the xss
+ // whitelist in `_getOptions()` .
+ return `
+
+ `;
+ }
+
+ /**
+ * Master render method.
+ * @returns A rendered template.
+ */
+ protected render(): TemplateResult | void {
+ if (!this.hass || !this.view || !this.timelineConfig) {
+ return;
+ }
+
+ const thumbnailsConfig = this.timelineConfig.controls.thumbnails;
+ const timelineClasses = {
+ timeline: true,
+ 'left-margin': thumbnailsConfig.mode === 'left',
+ 'right-margin': thumbnailsConfig.mode === 'right',
+ };
+
+ return html` {
+ request.hass = this.hass;
+ }}
+ class="${classMap(timelineClasses)}"
+ ${ref(this._refTimeline)}
+ >
`;
+ }
+
+ /**
+ * Get the number of seconds to seek into a video stream consisting of the
+ * provided segments to reach the target time provided.
+ * @param time Target time.
+ * @param segments A RecordingSegments object.
+ * @returns
+ */
+ protected _getSeekTime(time: Date, segments: RecordingSegments): number | null {
+ if (!segments.length) {
+ return null;
+ }
+ const target = getUnixTime(time);
+ const hourStart = getUnixTime(startOfHour(time));
+ let seekSeconds = 0;
+
+ // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
+ for (const segment of segments) {
+ if (segment.start_time > target) {
+ break;
+ }
+ const start = segment.start_time < hourStart ? hourStart : segment.start_time;
+ const end = segment.end_time > target ? target : segment.end_time;
+ seekSeconds += end - start;
+ }
+ return seekSeconds;
+ }
+
+ /**
+ * Create recording objects.
+ * @param results A map of camera ID to a CameraRecordings object.
+ * @param time The target time for the recordings.
+ * @param onlyMatchingHour If `true` only shows the hour matching the target
+ * for the provided cameras, otherwise shows all hours.
+ * @returns
+ */
+ protected _createRecordingChildren(
+ results: Map,
+ time: Date,
+ onlyMatchingHour: boolean,
+ ): FrigateBrowseMediaSource[] {
+ const children: FrigateBrowseMediaSource[] = [];
+ const processedCameras: Set = new Set();
+
+ // Get results in the order the cameras are specified in the configuration.
+ for (const camera of this.cameras?.keys() || []) {
+ const recording = results.get(camera);
+ const config = this.cameras?.get(camera);
+ if (!recording || !config?.frigate.camera_name) {
+ continue;
+ }
+
+ // There is a single set of recordings for a given Frigate camera name.
+ // Zones on that same camera do not get separate recordings. The card may
+ // have multiple instances of the same camera for different zones, so
+ // need to enforce uniqueness here.
+ const uniqueID = getUniqueFrigateCameraID(config);
+ if (processedCameras.has(uniqueID)) {
+ continue;
+ }
+ processedCameras.add(uniqueID);
+
+ const seekSeconds = this._getSeekTime(time, recording.segments);
+ if (seekSeconds === null) {
+ continue;
+ }
+
+ for (const dayData of recording.summary) {
+ for (const hourData of dayData.hours) {
+ const hour = add(dayData.day, { hours: hourData.hour });
+ const startHour = startOfHour(hour);
+ const endHour = endOfHour(hour);
+ const isMatchingHour = time >= startHour && time <= endHour;
+
+ if (!onlyMatchingHour || isMatchingHour) {
+ children.push(
+ createVideoChild(
+ `${prettifyTitle(config.frigate.camera_name)} ${format(
+ hour,
+ 'yyyy-MM-dd HH:mm',
+ )}`,
+ generateRecordingIdentifier({
+ clientId: config.frigate.client_id,
+ year: dayData.day.getFullYear(),
+ month: dayData.day.getMonth() + 1,
+ day: dayData.day.getDate(),
+ hour: hourData.hour,
+ cameraName: config.frigate.camera_name,
+ }),
+ {
+ recording: {
+ camera: config.frigate.camera_name,
+ start_time: getUnixTime(startHour),
+ end_time: getUnixTime(endHour),
+ events: hourData.events,
+ ...(isMatchingHour && {
+ seek_seconds: seekSeconds,
+ seek_time: time.getTime() / 1000,
+ }),
+ },
+ },
+ ),
+ );
+ }
+ }
+ }
+ }
+ return children;
+ }
+
+ /**
+ * Change the view to a recording.
+ * @param time The time of the recording to show.
+ * @param camera An optional camera to show a recording of, otherwise all
+ * cameras are shown at the given time.
+ */
+ protected async _changeViewToRecording(time: Date, camera?: string): Promise {
+ if (!this.hass) {
+ return;
+ }
+
+ const before = endOfHour(time);
+ const after = startOfHour(time);
+ const results: Map = new Map();
+
+ const fetch = async (camera: string, config?: CameraConfig): Promise => {
+ if (!config || !config.frigate.camera_name || !this.hass) {
+ return;
+ }
+
+ try {
+ const cameraResults = await Promise.all([
+ getRecordingSegments(
+ this.hass,
+ config.frigate.client_id,
+ config.frigate.camera_name,
+ before,
+ after,
+ ),
+ getRecordingsSummary(
+ this.hass,
+ config.frigate.client_id,
+ config.frigate.camera_name,
+ ),
+ ]);
+ results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] });
+ } catch (e) {
+ errorToConsole(e as Error);
+ }
+ };
+ const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])];
+ await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera))));
+
+ const children = this._createRecordingChildren(results, time, !camera);
+ if (!children.length) {
+ return;
+ }
+
+ let childIndex = 0;
+ if (camera) {
+ childIndex = children.findIndex(
+ (child) =>
+ child.frigate?.recording &&
+ child.frigate.recording.start_time * 1000 === after.getTime(),
+ );
+ if (childIndex < 0) {
+ return;
+ }
+ }
+
+ this.view
+ ?.evolve({
+ view: 'media',
+ target: createEventParentForChildren(localize('common.recordings'), children),
+ childIndex: childIndex,
+ })
+ .dispatchChangeEvent(this);
+ }
+
+ /**
+ * Called whenever the range is in the process of being changed.
+ * @param properties
+ */
+ protected _timelineRangeChangeHandler(
+ properties: TimelineEventPropertiesResult,
+ ): void {
+ if (properties.event && this._pointerHeld) {
+ // An event will have been set when it's a human changes the range.
+ this._ignoreClick = true;
+ }
+ }
+
+ /**
+ * Called whenever the timeline is clicked.
+ * @param properties The properties of the timeline click event.
+ */
+ protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void {
+ // Calls to stopEventFromActivatingCardWideActions() are included for
+ // completeness. Timeline does not support card-wide events and they are
+ // disabled in card.ts in `_getMergedActions`.
+ if (properties.what === 'item' || this._ignoreClick) {
+ stopEventFromActivatingCardWideActions(properties.event);
+ }
+
+ if (!this._ignoreClick && properties.what && this.timelineConfig?.show_recordings) {
+ if (['background', 'group-label'].includes(properties.what)) {
+ stopEventFromActivatingCardWideActions(properties.event);
+ this._changeViewToRecording(properties.time, String(properties.group));
+ } else if (properties.what === 'axis') {
+ stopEventFromActivatingCardWideActions(properties.event);
+ this._changeViewToRecording(properties.time);
+ }
+ }
+
+ this._ignoreClick = false;
+ }
+
+ /**
+ * Get a broader prefetch window from a start and end basis.
+ * @param start The earlier date.
+ * @param end The later date.
+ * @returns An object with a `start` and `end` key to prefetch.
+ */
+ protected _getPrefetchWindow(start: Date, end: Date): [Date, Date] {
+ const delta = differenceInSeconds(end, start);
+ return [sub(start, { seconds: delta }), add(end, { seconds: delta })];
+ }
+
+ /**
+ * Handle a range change in the timeline.
+ * @param properties vis.js provided range information.
+ */
+ protected _timelineRangeHandler(properties: {
+ start: Date;
+ end: Date;
+ byUser: boolean;
+ event: Event;
+ }): void {
+ if (!properties.byUser) {
+ return;
+ }
+ if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
+ const [prefetchStart, prefetchEnd] = this._getPrefetchWindow(
+ properties.start,
+ properties.end,
+ );
+ this._data
+ .fetchIfNecessary(
+ this,
+ this.hass,
+ this.cameras,
+ this.timelineConfig.media,
+ prefetchStart,
+ prefetchEnd,
+ this.timelineConfig.show_recordings,
+ )
+ .then(() => {
+ if (this._timeline) {
+ const thumbnails = this._generateThumbnails();
+ // Update the view to reflect the new thumbnails and the timeline
+ // window in the context.
+ this.view
+ ?.evolve({
+ target: thumbnails?.target ?? null,
+ childIndex: thumbnails?.childIndex ?? null,
+ })
+ .mergeInContext(this._generateTimelineContext(true))
+ .dispatchChangeEvent(this);
+ }
+ });
+ }
+ }
+
+ /**
+ * Called when an object on the timeline is selected.
+ * @param data The data about the selection.
+ * @returns
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ protected _timelineSelectHandler(data: { items: string[]; event: Event }): void {
+ if (!this.view?.target || !this.view?.target.children) {
+ return;
+ }
+
+ const childIndex = data.items.length
+ ? this.view.target.children.findIndex(
+ (child) => child.frigate?.event?.id === data.items[0],
+ )
+ : null;
+
+ this.view
+ ?.evolve({
+ childIndex: childIndex,
+ })
+ .dispatchChangeEvent(this);
+
+ if (childIndex !== null && childIndex >= 0) {
+ dispatchFrigateCardEvent(this, 'thumbnails:open');
+ } else {
+ dispatchFrigateCardEvent(this, 'thumbnails:close');
+ }
+ }
+
+ /**
+ * Regenerate the thumbnails from the timeline events.
+ * @returns An object with two keys, or null on error. The keys are `target`
+ * containing all the thumbnails, and `childIndex` to refer to the currently
+ * selected thumbnail.
+ */
+ protected _generateThumbnails(): {
+ target: FrigateBrowseMediaSource;
+ childIndex: number | null;
+ } | null {
+ if (!this._timeline) {
+ return null;
+ }
+
+ /**
+ * Sort the timeline items most recent to least recent.
+ * @param a The first item.
+ * @param b The second item.
+ * @returns -1, 0, 1 (standard array sort function configuration).
+ */
+ const sortEvent = (
+ a: FrigateCardTimelineItem,
+ b: FrigateCardTimelineItem,
+ ): number => {
+ if (a.start < b.start) {
+ return 1;
+ }
+ if (a.start > b.start) {
+ return -1;
+ }
+ return 0;
+ };
+
+ const selected = this._timeline.getSelection();
+ let childIndex = -1;
+ const children: FrigateBrowseMediaSource[] = [];
+ this._data.dataset.get({ order: sortEvent }).forEach((item) => {
+ if (item.event && item.source) {
+ children.push(item.source);
+ if (selected.includes(item.event.id)) {
+ childIndex = children.length - 1;
+ }
+ }
+ });
+ if (!children.length) {
+ return null;
+ }
+
+ return {
+ target: createEventParentForChildren('Timeline events', children),
+ childIndex: childIndex < 0 ? null : childIndex,
+ };
+ }
+
+ /**
+ * Build the visjs dataset to render on the timeline.
+ * @returns The dataset.
+ */
+ protected _getGroups(): DataGroupCollectionType {
+ const groups: FrigateCardGroupData[] = [];
+ const processedCameras: Set = new Set();
+
+ this.cameras?.forEach((cameraConfig, camera) => {
+ const frigateCameraID = getUniqueFrigateCameraEventsID(cameraConfig);
+ if (
+ cameraConfig.frigate.camera_name &&
+ cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE &&
+ !processedCameras.has(frigateCameraID)
+ ) {
+ processedCameras.add(frigateCameraID);
+ groups.push({
+ id: camera,
+ content: getCameraTitle(this.hass, cameraConfig),
+ });
+ }
+ });
+ return new DataSet(groups);
+ }
+
+ /**
+ * Given an event get an appropriate start/end time window around the event.
+ * @param event The FrigateEvent to consider.
+ * @returns A tuple of start/end date.
+ */
+ protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] {
+ const windowSeconds = this._getConfiguredWindowSeconds();
+ if (event.end_time) {
+ if (event.end_time - event.start_time > windowSeconds) {
+ // If the event is larger than the configured window, only show the most
+ // recent portion of the event that fits in the window.
+ return [
+ sub(fromUnixTime(event.end_time), { seconds: windowSeconds }),
+ fromUnixTime(event.end_time),
+ ];
+ } else {
+ // If the event is shorter than the configured window, center the event
+ // in the window.
+ const gap = windowSeconds - (event.end_time - event.start_time);
+ return [
+ sub(fromUnixTime(event.start_time), { seconds: gap / 2 }),
+ add(fromUnixTime(event.end_time), { seconds: gap / 2 }),
+ ];
+ }
+ }
+ // If there's no end-time yet, place the start-time in the center of the
+ // time window.
+ return [
+ sub(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }),
+ add(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }),
+ ];
+ }
+
+ /**
+ * Get the configured window length in seconds.
+ */
+ protected _getConfiguredWindowSeconds(): number {
+ return (
+ this.timelineConfig?.window_seconds ??
+ frigateCardConfigDefaults.timeline.window_seconds
+ );
+ }
+
+ /**
+ * Get desired timeline start/end time.
+ * @returns A tuple of start/end date.
+ */
+ protected _getStartEnd(): [Date, Date] {
+ const event = this.view?.target?.frigate?.event;
+ if (event) {
+ return this._getStartEndFromEvent(event);
+ }
+ const end = new Date();
+ const start = sub(end, {
+ seconds: this._getConfiguredWindowSeconds(),
+ });
+ return [start, end];
+ }
+
+ /**
+ * Determine if the timeline should use clustering.
+ * @returns `true` if the timeline should cluster, `false` otherwise.
+ */
+ protected _isClustering(): boolean {
+ return (
+ !!this.timelineConfig?.clustering_threshold &&
+ this.timelineConfig.clustering_threshold > 0
+ );
+ }
+
+ /**
+ * Handle timeline resize.
+ */
+ protected _getOptions(): TimelineOptions | void {
+ if (!this.timelineConfig) {
+ return;
+ }
+
+ const [start, end] = this._getStartEnd();
+
+ // Configuration for the Timeline, see:
+ // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options
+ return {
+ cluster: this._isClustering()
+ ? {
+ // It would be better to automatically calculate `maxItems` from the
+ // rendered height of the timeline (or group within the timeline) so
+ // as to not waste vertical space (e.g. after the user changes to
+ // fullscreen mode). Unfortunately this is not easy to do, as we
+ // don't know the height of the timeline until after it renders --
+ // and if we adjust `maxItems` then we can get into an infinite
+ // resize loop. Adjusting the `maxItems` of a timeline, after it's
+ // created, also does not appear to work as expected.
+ maxItems: this.timelineConfig.clustering_threshold,
+
+ clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
+ // Never include the target media in a cluster, and never group
+ // different object types together (e.g. person and car).
+ return (
+ [first.type, second.type].every((type) => type !== 'background') &&
+ first.type === second.type &&
+ !!first.id &&
+ first.id !== this.view?.media?.frigate?.event?.id &&
+ !!second.id &&
+ second.id != this.view?.media?.frigate?.event?.id &&
+ (first).event?.label ===
+ (second).event?.label
+ );
+ },
+ }
+ : (false as TimelineOptionsCluster),
+ minHeight: '100%',
+ maxHeight: '100%',
+ zoomMax: 1 * 24 * 60 * 60 * 1000,
+ zoomMin: 1 * 1000,
+ selectable: true,
+ start: start,
+ end: end,
+ groupHeightMode: 'fixed',
+ tooltip: {
+ followMouse: true,
+ overflowMethod: 'cap',
+ template: this._getTooltip.bind(this),
+ },
+ xss: {
+ disabled: false,
+ filterOptions: {
+ whiteList: {
+ 'frigate-card-timeline-thumbnail': [
+ 'details',
+ 'thumbnail',
+ 'label',
+ 'event',
+ ],
+ div: ['title'],
+ span: ['style'],
+ },
+ },
+ },
+ };
+ }
+
+ /**
+ * Determine if the component should be updated.
+ * @param _changedProps The changed properties.
+ * @returns
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ protected shouldUpdate(_changedProps: PropertyValues): boolean {
+ return !!this.hass && !!this.cameras && this.cameras.size > 0;
+ }
+
+ /**
+ * Update the timeline from the view object.
+ */
+ protected async _updateTimelineFromView(): Promise {
+ if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
+ return;
+ }
+
+ const event = this.view?.media?.frigate?.event;
+ const [windowStart, windowEnd] = event
+ ? this._getStartEndFromEvent(event)
+ : this._getStartEnd();
+
+ const [prefetchStart, prefetchEnd] = this._getPrefetchWindow(windowStart, windowEnd);
+ const fetched = await this._data.fetchIfNecessary(
+ this,
+ this.hass,
+ this.cameras,
+ this.timelineConfig.media,
+ prefetchStart,
+ prefetchEnd,
+ this.timelineConfig.show_recordings,
+ );
+
+ if (!this._timeline) {
+ return;
+ }
+
+ this._timeline.setSelection(event ? [event.id] : [], {
+ focus: false,
+ animation: {
+ animation: false,
+ zoom: false,
+ },
+ });
+
+ // Regenerate the thumbnails after the selection, to allow the new selection
+ // to be in the generated view.
+ const context = this.view.context?.timeline;
+ const timelineWindow = this._timeline.getWindow();
+
+ if (context?.window) {
+ if (!isEqual(context.window, timelineWindow)) {
+ this._timeline.setWindow(context.window.start, context.window.end);
+ }
+ } else if (event) {
+ const eventStart = new Date(event.start_time * 1000);
+ const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0;
+
+ if (
+ eventStart < timelineWindow.start ||
+ eventStart > timelineWindow.end ||
+ (eventEnd && (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end))
+ ) {
+ this._timeline.setWindow(windowStart, windowEnd);
+ }
+
+ if (this._isClustering()) {
+ // Hack: Clustering may not update unless the dataset changes, artifically
+ // update the dataset to ensure the newly selected item cannot be included
+ // in a cluster.
+ const item = this._data.dataset.get(event.id);
+ if (item) {
+ this._data.dataset.updateOnly(item);
+ }
+ }
+ } else {
+ this._timeline.setWindow(windowStart, windowEnd);
+ }
+
+ // Only generate thumbnails if an actual fetch occurred, to avoid getting
+ // stuck in a loop (the subsequent fetches will not actually fetch since the
+ // data will have been cached).
+ //
+ // Timeline receives a new `view`
+ // -> Events fetched
+ // -> Thumbnails generated
+ // -> New view dispatched (to load thumbnails into outer carousel).
+ // -> New view received ... [loop]
+
+ if (fetched) {
+ const thumbnails = this._generateThumbnails();
+ this.view
+ ?.evolve({
+ target: thumbnails?.target ?? null,
+ childIndex: thumbnails?.childIndex ?? null,
+ })
+ .mergeInContext(this._generateTimelineContext(false))
+ .dispatchChangeEvent(this);
+ }
+ }
+
+ /**
+ * Generate the context for timeline views.
+ * @param addWindow Whether or not to include the timeline window. If `false`
+ * the window is preserved if it is already in the context.
+ * @returns The TimelineViewContext object.
+ */
+ protected _generateTimelineContext(addWindow: boolean): ViewContext {
+ const currentContext = this.view?.context?.timeline;
+ const newContext: TimelineViewContext = {}
+
+ if (addWindow && this._timeline) {
+ newContext.window = this._timeline.getWindow();
+ } else if (currentContext?.window) {
+ newContext.window = currentContext.window;
+ }
+ if (this._data.lastFetchDate) {
+ newContext.dateFetch = this._data.lastFetchDate;
+ }
+ return Object.keys(newContext) ? {timeline: newContext} : {};
+ }
+
+ /**
+ * Called when an update will occur.
+ * @param changedProps The changed properties
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('timelineConfig')) {
+ if (this.timelineConfig?.controls.thumbnails.size) {
+ this.style.setProperty(
+ '--frigate-card-thumbnail-size',
+ `${this.timelineConfig.controls.thumbnails.size}px`,
+ );
+ }
+ if (this.timelineConfig?.show_recordings) {
+ this.setAttribute('recordings', '');
+ } else {
+ this.removeAttribute('recordings');
+ }
+ }
+ }
+
+ /**
+ * Called when the component is updated.
+ * @param changedProperties The changed properties if any.
+ */
+ protected updated(changedProperties: PropertyValues): void {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('cameras')) {
+ this._data.clear();
+ this._timeline?.destroy();
+ this._timeline = undefined;
+ }
+
+ const options = this._getOptions();
+ if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) {
+ if (this._timeline) {
+ this._timeline.setOptions(options);
+ } else {
+ // Don't show an empty timeline, show a message instead.
+ const groups = this._getGroups();
+ if (!groups.length) {
+ dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', {
+ icon: 'mdi:chart-gantt',
+ });
+ return;
+ }
+
+ this._timeline = new Timeline(
+ this._refTimeline.value,
+ this._data.dataset,
+ groups,
+ options,
+ );
+ this._timeline.on('select', this._timelineSelectHandler.bind(this));
+ this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
+ this._timeline.on('click', this._timelineClickHandler.bind(this));
+ this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this));
+
+ // This complexity exists to ensure we can tell between a click that
+ // causes the timeline zoom/range to change, and a 'static' click on the
+ // // timeline (which may need to trigger a card wide event).
+ this._timeline.on('mouseDown', () => {
+ this._pointerHeld = true;
+ this._ignoreClick = false;
+ });
+ this._timeline.on('mouseUp', () => {
+ this._pointerHeld = false;
+ });
+ }
+ }
+
+ if (changedProperties.has('view')) {
+ this._updateTimelineFromView();
+ }
+ }
+
+ /**
+ * Return compiled CSS styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(timelineCoreStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-timeline-thumbnail': FrigateCardTimelineThumbnail;
+ 'frigate-card-timeline-core': FrigateCardTimelineCore;
+ 'frigate-card-timeline': FrigateCardTimeline;
+ }
+}
diff --git a/src/components/title-control.ts b/src/components/title-control.ts
index 254a2b98..bdd524e1 100644
--- a/src/components/title-control.ts
+++ b/src/components/title-control.ts
@@ -83,3 +83,9 @@ export class FrigateCardTitleControl extends LitElement {
return unsafeCSS(titleStyle);
}
}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "frigate-card-title-control": FrigateCardTitleControl
+ }
+}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index 21dbc54f..6518a123 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -1,107 +1,131 @@
+import { Task } from '@lit-labs/task';
+import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
+import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import {
CSSResultGroup,
+ html,
LitElement,
PropertyValues,
TemplateResult,
- html,
unsafeCSS,
} from 'lit';
-import { BrowseMediaUtil } from '../browse-media-util.js';
-import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
-import { HomeAssistant } from 'custom-card-helpers';
-import { Task } from '@lit-labs/task';
-import { createRef, Ref, ref } from 'lit/directives/ref.js';
+import { guard } from 'lit/directives/guard.js';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
-
-import { AutoMediaPlugin } from './embla-plugins/automedia.js';
-import type {
+import { createRef, Ref, ref } from 'lit/directives/ref.js';
+import { renderProgressIndicator } from '../components/message.js';
+import viewerStyle from '../scss/viewer.scss';
+import viewerCarouselStyle from '../scss/viewer-carousel.scss';
+import {
BrowseMediaNeighbors,
BrowseMediaQueryParameters,
- BrowseMediaSource,
CameraConfig,
ExtendedHomeAssistant,
- MediaShowInfo,
+ FrigateBrowseMediaSource,
+ frigateCardConfigDefaults,
+ FrigateCardMediaPlayer,
+ MediaLoadedInfo,
TransitionEffect,
ViewerConfig,
} from '../types.js';
-import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js';
-import { FrigateCardNextPreviousControl } from './next-prev-control.js';
+import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
+import { contentsChanged } from '../utils/basic.js';
import {
- FrigateCardThumbnailCarousel,
- ThumbnailCarouselTap,
-} from './thumbnail-carousel.js';
-import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
-import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
+ fetchLatestMediaAndDispatchViewChange,
+ getEventStartTime,
+ getFullDependentBrowseMediaQueryParametersOrDispatchError,
+ isTrueMedia,
+ multipleBrowseMediaQueryMerged,
+ overrideMultiBrowseMediaQueryParameters,
+} from '../utils/ha/browse-media.js';
+import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { View } from '../view.js';
+import { AutoMediaPlugin } from './embla-plugins/automedia.js';
+import { Lazyload } from './embla-plugins/lazyload.js';
import {
- contentsChanged,
- createMediaShowInfo,
- dispatchErrorMessageEvent,
- stopEventFromActivatingCardWideActions,
-} from '../common.js';
-import { renderProgressIndicator } from '../components/message.js';
-
+ FrigateCardMediaCarousel,
+ IMG_EMPTY,
+ wrapRawMediaLoadedEventForCarousel,
+ wrapMediaLoadedEventForCarousel,
+} from './media-carousel.js';
import './next-prev-control.js';
import './title-control.js';
-
-import viewerStyle from '../scss/viewer.scss';
-import viewerCoreStyle from '../scss/viewer-core.scss';
+import '../patches/ha-hls-player';
+import './surround-thumbnails';
+import { EmblaCarouselPlugins } from './carousel.js';
+import { renderTask } from '../utils/task.js';
+import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
@customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- protected view?: Readonly;
+ public view?: Readonly;
@property({ attribute: false })
- protected viewerConfig?: ViewerConfig;
+ public viewerConfig?: ViewerConfig;
@property({ attribute: false })
- protected cameraConfig?: CameraConfig;
+ public cameras?: Map;
@property({ attribute: false })
- protected resolvedMediaCache?: ResolvedMediaCache;
+ public resolvedMediaCache?: ResolvedMediaCache;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
- if (!this.hass || !this.view || !this.cameraConfig) {
+ if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) {
return;
}
const browseMediaQueryParameters =
- BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
+ getFullDependentBrowseMediaQueryParametersOrDispatchError(
this,
- this.view,
- this.cameraConfig,
+ this.hass,
+ this.cameras,
+ this.view.camera,
);
- if (!browseMediaQueryParameters) {
- return;
- }
if (!this.view.target) {
- BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
+ // If the target is not specified, the view must tell us which mediaType
+ // to search for. When the target *is* specified, the view is not required
+ // to indicate the media type (e.g. the mixed 'events' view from the
+ // timeline).
+ const mediaType = this.view.getMediaType();
+ if (!browseMediaQueryParameters || !mediaType) {
+ return;
+ }
+
+ fetchLatestMediaAndDispatchViewChange(
this,
this.hass,
this.view,
- browseMediaQueryParameters,
+ overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
+ mediaType: mediaType,
+ }),
);
return renderProgressIndicator();
}
- return html`
- `;
+
+
+ `;
}
/**
@@ -112,89 +136,15 @@ export class FrigateCardViewer extends LitElement {
}
}
-@customElement('frigate-card-viewer-core')
-export class FrigateCardViewerCore extends LitElement {
- @property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
-
- @property({ attribute: false })
- protected view?: Readonly;
-
- // See note on viewerConfig in .
- @property({ attribute: false, hasChanged: contentsChanged })
- protected viewerConfig?: ViewerConfig;
-
- @property({ attribute: false })
- protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
-
- @property({ attribute: false })
- protected resolvedMediaCache?: ResolvedMediaCache;
-
- protected _viewerCarouselRef: Ref = createRef();
- protected _thumbnailCarouselRef: Ref = createRef();
-
- protected _syncThumbnailCarousel(): void {
- const mediaSelected = this._viewerCarouselRef.value?.carouselSelected();
- if (mediaSelected !== undefined) {
- this._thumbnailCarouselRef.value?.carouselScrollTo(mediaSelected);
- }
- }
-
- protected _renderThumbnails(): TemplateResult {
- if (!this.view || !this.viewerConfig) {
- return html``;
- }
-
- return html` ) => {
- this._viewerCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex);
- }}
- @frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)}
- >
- `;
- }
-
- protected render(): TemplateResult | void {
- if (!this.view || !this.viewerConfig) {
- return html``;
- }
- return html` ${this.viewerConfig &&
- this.viewerConfig.controls.thumbnails.mode === 'above'
- ? this._renderThumbnails()
- : ''}
-
-
- ${this.viewerConfig && this.viewerConfig.controls.thumbnails.mode === 'below'
- ? this._renderThumbnails()
- : ''}`;
- }
-
- /**
- * Get element styles.
- */
- static get styles(): CSSResultGroup {
- return unsafeCSS(viewerCoreStyle);
- }
-}
+const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
@customElement('frigate-card-viewer-carousel')
-export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
+export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
- protected hass?: HomeAssistant & ExtendedHomeAssistant;
+ public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
- protected view?: Readonly;
+ public view?: Readonly;
// Resetting the viewer configuration causes a full reset so ensure the config
// has actually changed with a full comparison (dynamic configuration
@@ -202,22 +152,27 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
// could lead to the address of the viewerConfig changing without it being
// semantically different).
@property({ attribute: false, hasChanged: contentsChanged })
- protected viewerConfig?: ViewerConfig;
+ public viewerConfig?: ViewerConfig;
@property({ attribute: false })
- protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
+ public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null;
@property({ attribute: false })
- protected resolvedMediaCache?: ResolvedMediaCache;
+ public resolvedMediaCache?: ResolvedMediaCache;
- // Mapping of slide # to BrowseMediaSource child #.
+ protected _refMediaCarousel: Ref = createRef();
+
+ // Mapping of slide # to FrigateBrowseMediaSource child #.
// (Folders are not media items that can be rendered).
protected _slideToChild: Record = {};
// A task to resolve target media if lazy loading is disabled.
- protected _mediaResolutionTask = new Task<[BrowseMediaSource | undefined], void>(
+ protected _mediaResolutionTask = new Task<
+ [FrigateBrowseMediaSource | null | undefined],
+ void
+ >(
this,
- async ([target]: (BrowseMediaSource | undefined)[]): Promise => {
+ async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise => {
for (
let i = 0;
!this.viewerConfig?.lazy_load &&
@@ -227,12 +182,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
i < (target.children || []).length;
++i
) {
- if (BrowseMediaUtil.isTrueMedia(target.children[i])) {
- await ResolvedMediaUtil.resolveMedia(
- this.hass,
- target.children[i],
- this.resolvedMediaCache,
- );
+ if (isTrueMedia(target.children[i])) {
+ await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
}
}
},
@@ -244,22 +195,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
- if (this._carousel && changedProperties.has('viewerConfig')) {
- this._destroyCarousel();
- }
+ const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel();
- if (this._carousel && changedProperties.has('view')) {
+ if (frigateCardCarousel && changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined;
if (oldView) {
- if (oldView.target != this.view?.target) {
- // If the media target is different entirely, reset the carousel.
- this._destroyCarousel();
- } else if (this.view?.childIndex != oldView.childIndex) {
- const slide = this._getSlideForChild(this.view?.childIndex);
- if (slide !== undefined && slide !== this.carouselSelected()) {
+ if (
+ oldView.target === this.view?.target &&
+ this.view.childIndex != oldView.childIndex
+ ) {
+ const slide = this._getSlideForChild(this.view.childIndex);
+ if (
+ slide !== null &&
+ slide !== frigateCardCarousel.getCarouselSelected()?.index
+ ) {
// If the media target is the same as already loaded, but isn't of
// the selected slide, scroll to that slide.
- this.carouselScrollTo(slide);
+ frigateCardCarousel.carouselScrollTo(slide);
}
}
}
@@ -269,47 +221,29 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
}
/**
- * Play the media on the selected slide.
+ * Get the slide number given a media child number.
+ * @param childIndex The child index (relative to `view.target`)
+ * @returns A number or null if the child is not found.
*/
- protected _autoPlayHandler(): void {
- if (this.viewerConfig?.auto_play) {
- super._autoPlayHandler();
- }
- }
-
- /**
- * Unmute the media on the selected slide.
- */
- protected _autoUnmuteHandler(): void {
- if (this.viewerConfig?.auto_unmute) {
- super._autoUnmuteHandler();
- }
- }
-
- protected _destroyCarousel(): void {
- super._destroyCarousel();
-
- // Notes on instance variables:
- // * this._slideToChild: This is set as part of each render and does not
- // need to be destroyed here.
- }
-
- protected _getSlideForChild(childIndex: number | undefined): number | undefined {
- if (childIndex === undefined) {
- return undefined;
+ protected _getSlideForChild(childIndex: number | null | undefined): number | null {
+ if (childIndex === undefined || childIndex === null) {
+ return null;
}
const slideIndex = Object.keys(this._slideToChild).find(
(key) => this._slideToChild[key] === childIndex,
);
- return slideIndex !== undefined ? Number(slideIndex) : undefined;
+ return slideIndex !== undefined ? Number(slideIndex) : null;
}
/**
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
- protected _getTransitionEffect(): TransitionEffect | undefined {
- return this.viewerConfig?.transition_effect;
+ protected _getTransitionEffect(): TransitionEffect {
+ return (
+ this.viewerConfig?.transition_effect ??
+ frigateCardConfigDefaults.media_viewer.transition_effect
+ );
}
/**
@@ -319,32 +253,67 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
protected _getOptions(): EmblaOptionsType {
return {
// Start the carousel on the selected child number.
- startIndex: this._getSlideForChild(this.view?.childIndex),
- draggable: this.viewerConfig?.draggable,
+ startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0,
+ draggable: this.viewerConfig?.draggable ?? true,
};
}
/**
- * Get the Embla plugins to use.
- * @returns An EmblaOptionsType object or undefined for no options.
+ * The the HLS player on a slide (or current slide if not provided.)
+ * @param slide An optional slide.
+ * @returns The FrigateCardMediaPlayer or null if not found.
*/
- protected _getPlugins(): EmblaPluginType[] | undefined {
+ protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
+ if (!slide) {
+ slide = this._refMediaCarousel.value
+ ?.frigateCardCarousel()
+ ?.getCarouselSelected()?.element;
+ }
+
+ return (
+ (slide?.querySelector(FRIGATE_CARD_HLS_SELECTOR) as FrigateCardMediaPlayer) ?? null
+ );
+ }
+
+ /**
+ * Get the Embla plugins to use.
+ * @returns A list of EmblaOptionsTypes.
+ */
+ protected _getPlugins(): EmblaPluginType[] {
return [
- Lazyload({
- lazyloadCallback: this.viewerConfig?.lazy_load
- ? this._lazyloadSlide.bind(this)
- : undefined,
- }),
- // Don't need autoplay/pause for snapshots.
- ...(this.view?.is('clip')
+ // Only enable wheel plugin if there is more than one media item.
+ ...(this.view &&
+ this.view.target &&
+ this.view.target.children &&
+ this.view.target.children.length > 1
? [
- AutoMediaPlugin({
- playerSelector: 'frigate-card-ha-hls-player',
- autoPlayWhenVisible: !!this.viewerConfig?.auto_play,
- autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute,
+ WheelGesturesPlugin({
+ // Whether the carousel is vertical or horizontal, interpret y-axis wheel
+ // gestures as scrolling for the carousel.
+ forceWheelAxis: 'y',
}),
]
: []),
+ Lazyload({
+ ...(this.viewerConfig?.lazy_load && {
+ lazyLoadCallback: this._lazyloadSlide.bind(this),
+ }),
+ }),
+ AutoMediaPlugin({
+ playerSelector: FRIGATE_CARD_HLS_SELECTOR,
+ ...(this.viewerConfig?.auto_play && {
+ autoPlayCondition: this.viewerConfig.auto_play,
+ }),
+ ...(this.viewerConfig?.auto_pause && {
+ autoPauseCondition: this.viewerConfig.auto_pause,
+ }),
+ ...(this.viewerConfig?.auto_mute && {
+ autoMuteCondition: this.viewerConfig.auto_mute,
+ }),
+ ...(this.viewerConfig?.auto_unmute && {
+ autoUnmuteCondition: this.viewerConfig.auto_unmute,
+ }),
+ }),
];
}
@@ -358,7 +327,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
!this.view ||
!this.view.target ||
!this.view.target.children ||
- this.view.childIndex === undefined
+ this.view.childIndex === null
) {
return null;
}
@@ -367,7 +336,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let prevIndex: number | null = null;
for (let i = this.view.childIndex - 1; i >= 0; i--) {
const media = this.view.target.children[i];
- if (media && BrowseMediaUtil.isTrueMedia(media)) {
+ if (media && isTrueMedia(media)) {
prevIndex = i;
break;
}
@@ -377,7 +346,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let nextIndex: number | null = null;
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
const media = this.view.target.children[i];
- if (media && BrowseMediaUtil.isTrueMedia(media)) {
+ if (media && isTrueMedia(media)) {
nextIndex = i;
break;
}
@@ -398,7 +367,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* @returns The view that would show the matching clip.
*/
protected async _findRelatedClipView(
- snapshot: BrowseMediaSource,
+ snapshot: FrigateBrowseMediaSource,
): Promise {
if (
!this.hass ||
@@ -411,7 +380,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return null;
}
- const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot);
+ const snapshotStartTime = getEventStartTime(snapshot);
if (!snapshotStartTime) {
return null;
}
@@ -429,10 +398,10 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let latest: number | null = null;
for (let i = 0; i < this.view.target.children.length; i++) {
const child = this.view.target.children[i];
- if (!BrowseMediaUtil.isTrueMedia(child)) {
+ if (!isTrueMedia(child)) {
continue;
}
- const startTime = BrowseMediaUtil.extractEventStartTime(child);
+ const startTime = getEventStartTime(child);
if (startTime && (earliest === null || startTime < earliest)) {
earliest = startTime;
@@ -445,15 +414,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return null;
}
- let clips: BrowseMediaSource | null;
+ let clips: FrigateBrowseMediaSource | null;
- try {
- clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
- ...this.browseMediaQueryParameters,
+ const params = overrideMultiBrowseMediaQueryParameters(
+ this.browseMediaQueryParameters,
+ {
mediaType: 'clips',
before: latest,
after: earliest,
- });
+ },
+ );
+
+ try {
+ clips = await multipleBrowseMediaQueryMerged(this.hass, params);
} catch (e) {
// This is best effort.
return null;
@@ -465,17 +438,15 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
for (let i = 0; i < clips.children.length; i++) {
const child = clips.children[i];
- if (!BrowseMediaUtil.isTrueMedia(child)) {
+ if (!isTrueMedia(child)) {
continue;
}
- const clipStartTime = BrowseMediaUtil.extractEventStartTime(child);
+ const clipStartTime = getEventStartTime(child);
if (clipStartTime && clipStartTime === snapshotStartTime) {
- return new View({
+ return this.view.evolve({
view: 'clip',
- camera: this.view.camera,
target: clips,
childIndex: i,
- previous: this.view,
});
}
}
@@ -485,26 +456,39 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
/**
* Handle the user selecting a new slide in the carousel.
*/
- protected _selectSlideSetViewHandler(): void {
- if (!this._carousel || !this.view) {
+ protected _setViewHandler(): void {
+ if (!this._refMediaCarousel.value || !this.view) {
return;
}
// Update the childIndex in the view.
- const slidesInView = this._carousel.slidesInView(true);
- if (slidesInView.length) {
- const childIndex = this._slideToChild[slidesInView[0]];
+ const selected = this._refMediaCarousel.value
+ .frigateCardCarousel()
+ ?.getCarouselSelected()?.index;
+ if (selected !== undefined) {
+ const childIndex = this._slideToChild[selected];
if (childIndex !== undefined) {
this.view
.evolve({
childIndex: childIndex,
- previous: this.view,
})
.dispatchChangeEvent(this);
}
}
}
+ /**
+ * Ensure media URLs use the correct HA URL (relevant for Chromecast where the
+ * default location will be the Chromecast receiver, not HA).
+ * @param url The media URL
+ */
+ protected _canonicalizeHAURL(url?: string): string | undefined {
+ if (this.hass && url && url.startsWith('/')) {
+ return this.hass.hassUrl(url);
+ }
+ return url;
+ }
+
/**
* Lazy load a slide.
* @param index The index of the slide to lazy load.
@@ -515,17 +499,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
const childIndex: number | undefined = this._slideToChild[index];
if (
- childIndex == undefined ||
+ childIndex === undefined ||
!this.hass ||
!this.view ||
!this.view.target ||
!this.view.target.children ||
- !BrowseMediaUtil.isTrueMedia(this.view.target.children[childIndex])
+ !isTrueMedia(this.view.target.children[childIndex])
) {
return;
}
- ResolvedMediaUtil.resolveMedia(
+ resolveMedia(
this.hass,
this.view.target.children[childIndex],
this.resolvedMediaCache,
@@ -538,43 +522,18 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
const img = slide.querySelector('img') as HTMLImageElement;
// Frigate >= 0.9.0+ clips.
- const hls_player = slide.querySelector(
- 'frigate-card-ha-hls-player',
- ) as HTMLElement & { url: string };
+ const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
+ url: string;
+ };
if (img) {
- img.src = resolvedMedia.url;
+ img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
} else if (hls_player) {
- hls_player.url = resolvedMedia.url;
+ hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || '';
}
});
}
- /**
- * Handle updating of the next/previous controls when the carousel is moved.
- */
- protected _selectSlideNextPreviousHandler(): void {
- const updateNextPreviousControl = (
- control: FrigateCardNextPreviousControl,
- direction: 'previous' | 'next',
- ): void => {
- const neighbors = this._getMediaNeighbors();
- const [prev, next] = [neighbors?.previous, neighbors?.next];
- const target = direction == 'previous' ? prev : next;
-
- control.disabled = target == null;
- control.title = target && target.title ? target.title : '';
- control.thumbnail = target && target.thumbnail ? target.thumbnail : undefined;
- };
-
- if (this._previousControlRef.value) {
- updateNextPreviousControl(this._previousControlRef.value, 'previous');
- }
- if (this._nextControlRef.value) {
- updateNextPreviousControl(this._nextControlRef.value, 'next');
- }
- }
-
/**
* Get slides to include in the render.
* @returns The slides to include in the render and an index keyed by slide
@@ -615,6 +574,16 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return true;
}
+ /**
+ * Called when an update will occur.
+ * @param changedProps The changed properties
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('viewerConfig')) {
+ updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
+ }
+ }
+
/**
* Render the element, resolving the media first if necessary.
*/
@@ -624,12 +593,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
// If lazy loading is not enabled, wait for the media resolver task to
// complete and show a progress indictator until this.
if (!this.viewerConfig?.lazy_load && !this._isMediaFullyResolved()) {
- return html`${this._mediaResolutionTask.render({
- initial: () => renderProgressIndicator(),
- pending: () => renderProgressIndicator(),
- error: (e: unknown) => dispatchErrorMessageEvent(this, (e as Error).message),
- complete: () => this._render(),
- })}`;
+ return renderTask(this, this._mediaResolutionTask, this._render.bind(this));
}
return this._render();
}
@@ -641,62 +605,93 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
protected _render(): TemplateResult | void {
const [slides, slideToChild] = this._getSlides();
this._slideToChild = slideToChild;
- if (!slides) {
+ if (!slides.length || !this.view?.media) {
return;
}
const neighbors = this._getMediaNeighbors();
const [prev, next] = [neighbors?.previous, neighbors?.next];
- return html`
-
{
- this._nextPreviousHandler('previous');
- stopEventFromActivatingCardWideActions(ev);
- }}
- >
-
-
{
- this._nextPreviousHandler('next');
- stopEventFromActivatingCardWideActions(ev);
- }}
- >
-
- ${this.view?.media
- ? html`
- `
- : ``} `;
+ // Notes on the below:
+ // - guard() is used to avoid reseting the carousel unless the
+ // options/plugins actually change.
+
+ return html`
+ {
+ this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
+ stopEventFromActivatingCardWideActions(ev);
+ }}
+ >
+ ${slides}
+ {
+ this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
+ stopEventFromActivatingCardWideActions(ev);
+ }}
+ >
+ `;
}
+ /**
+ * Fire a media show event when a slide is selected.
+ */
+ protected _recordingSeekHandler(): void {
+ // If this is a recording and play is desired to be started from a
+ // particular point, seek to that point. Use the media off the slide itself
+ // -- when the slide is changed, the media show event may be dispatched
+ // before this.view has been updated to reflect the new selection.
+ const player = this._getPlayer() as FrigateCardMediaPlayer & {
+ media?: FrigateBrowseMediaSource;
+ };
+ if (player && player.media && player.media.frigate?.recording?.seek_seconds) {
+ player.seek(player.media.frigate.recording.seek_seconds);
+ }
+ }
+
+ /**
+ * Render a single media item in the viewer carousel.
+ * @param mediaToRender The FrigateBrowseMediaSource to render.
+ * @param slideIndex The index of the slide to render.
+ * @returns A rendered template.
+ */
protected _renderMediaItem(
- mediaToRender: BrowseMediaSource,
+ mediaToRender: FrigateBrowseMediaSource,
slideIndex: number,
): TemplateResult | void {
// Skip folders as they cannot be rendered by this viewer.
if (
+ !this.hass ||
!this.view ||
!this.viewerConfig ||
- !BrowseMediaUtil.isTrueMedia(mediaToRender)
+ !isTrueMedia(mediaToRender) ||
+ !['video', 'image'].includes(mediaToRender.media_content_type)
) {
return;
}
@@ -707,9 +702,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return;
}
+ // The media is attached to the player as '.media' which is used in
+ // `_selectSlideMediaShowHandler` (and not used by the player itself).
return html`
- ${this.view.isClipRelatedView()
+ ${mediaToRender.media_content_type === 'video'
? html`
) =>
- this._mediaShowEventHandler(slideIndex, e)}
+ @frigate-card:media:loaded=${(e: CustomEvent) => {
+ wrapMediaLoadedEventForCarousel(slideIndex, e);
+ }}
>
`
: html`

{
- if (this._carousel?.clickAllowed()) {
+ if (
+ this._refMediaCarousel.value
+ ?.frigateCardCarousel()
+ ?.carouselClickAllowed()
+ ) {
this._findRelatedClipView(mediaToRender).then((view) => {
if (view) {
view.dispatchChangeEvent(this);
@@ -738,6 +745,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
}
}}
@load="${(e: Event) => {
+ const lazyloadPlugin = this._refMediaCarousel.value
+ ?.frigateCardCarousel()
+ ?.getCarouselPlugins()?.lazyload;
if (
// This handler will be called on the empty image (including
// an updated empty image that is the same dimensions large as
@@ -745,15 +755,27 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
// images in media-carousel.ts). Here we need to only call the
// media load handler on a 'real' load.
!lazyLoad ||
- (this._plugins['Lazyload'] as LazyloadType | undefined)?.hasLazyloaded(
- slideIndex,
- )
+ lazyloadPlugin?.hasLazyloaded(slideIndex)
) {
- this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e));
+ wrapRawMediaLoadedEventForCarousel(slideIndex, e);
}
}}"
/>`}
`;
}
+
+ /**
+ * Get element styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(viewerCarouselStyle);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
+ 'frigate-card-viewer': FrigateCardViewer;
+ }
}
diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts
index e2aec88b..f95ce8ce 100644
--- a/src/config-mgmt.ts
+++ b/src/config-mgmt.ts
@@ -1,28 +1,41 @@
-import { get, set } from 'lodash-es';
+import { cloneDeep, get, isEqual, set } from 'lodash-es';
import {
CONF_CAMERAS,
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
- CONF_CAMERAS_ARRAY_CAMERA_NAME,
- CONF_CAMERAS_ARRAY_CLIENT_ID,
- CONF_CAMERAS_ARRAY_LABEL,
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
- CONF_CAMERAS_ARRAY_URL,
- CONF_CAMERAS_ARRAY_ZONE,
- CONF_EVENT_VIEWER_AUTO_PLAY,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_IMAGE_URL,
+ CONF_LIVE_AUTO_UNMUTE,
+ CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
+ CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
+ CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_PRELOAD,
CONF_LIVE_WEBRTC_CARD,
+ CONF_MEDIA_VIEWER,
CONF_MENU,
+ CONF_MENU_BUTTONS_CAMERAS,
+ CONF_MENU_BUTTONS_CLIPS,
+ CONF_MENU_BUTTONS_DOWNLOAD,
+ CONF_MENU_BUTTONS_FRIGATE,
+ CONF_MENU_BUTTONS_FRIGATE_UI,
+ CONF_MENU_BUTTONS_FULLSCREEN,
+ CONF_MENU_BUTTONS_IMAGE,
+ CONF_MENU_BUTTONS_LIVE,
+ CONF_MENU_BUTTONS_SNAPSHOTS,
CONF_MENU_BUTTON_SIZE,
- CONF_MENU_MODE,
+ CONF_MENU_POSITION,
+ CONF_MENU_STYLE,
CONF_OVERRIDES,
CONF_VIEW_DEFAULT,
CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_ENTITIES,
} from './const';
-import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './types';
+import {
+ BUTTON_SIZE_MIN,
+ RawFrigateCardConfig,
+ RawFrigateCardConfigArray,
+ THUMBNAIL_WIDTH_MAX,
+ THUMBNAIL_WIDTH_MIN,
+} from './types';
/**
* Set a configuration value.
@@ -123,7 +136,7 @@ export const trimConfig = function (obj: RawFrigateCardConfig): boolean {
* @returns A new deeply-copied configuration.
*/
export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig {
- return JSON.parse(JSON.stringify(obj));
+ return cloneDeep(obj);
};
/**
@@ -131,7 +144,7 @@ export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardCo
* @param value The value.
* @returns `true` is the value is not an object.
*/
-const isNotObject = function (value: unknown) {
+const isNotObject = function (value: unknown): unknown | undefined {
return typeof value !== 'object' ? value : undefined;
};
@@ -140,10 +153,62 @@ const isNotObject = function (value: unknown) {
* @param value The value.
* @returns A number or undefined.
*/
-const toNumberOrIgnore = function (value: unknown) {
+const toNumberOrIgnore = function (value: unknown): number | undefined {
return isNaN(value as number) ? undefined : Number(value);
};
+/**
+ * Create a transform that will cap a numeric value.
+ * @param value The value.
+ * @returns A number or null.
+ */
+const createRangedTransform = function (
+ transform: (value: unknown) => unknown,
+ min?: number,
+ max?: number,
+): (valueIn: unknown) => unknown {
+ return (value: unknown): unknown => {
+ let transformed = transform(value);
+ if (typeof transformed !== 'number') {
+ return transformed;
+ }
+ transformed = min ? Math.max(min, transformed as number) : transformed;
+ transformed = max ? Math.min(max, transformed as number) : transformed;
+ return transformed;
+ };
+};
+
+/**
+ * Convert a value from 'XXpx' to XX (as a number).
+ * @param value Incoming value.
+ * @returns A number, null if the property should be deleted or undefined if it
+ * should be ignored.
+ */
+const toPixelsOrDelete = function (value: unknown): number | null | undefined {
+ // Ignore the value if it's a number.
+ if (typeof value === 'number') {
+ return undefined;
+ }
+ // Delete the value if it's not a string.
+ if (typeof value !== 'string') {
+ return null;
+ }
+ // Remove 'px' and return the number, unless it's an invalid number -- then
+ // delete it.
+ value = value.replace(/px$/i, '');
+ return isNaN(value as number) ? null : Number(value);
+};
+
+/**
+ * Request a property be deleted.
+ * @param _value Inbound value (not required).
+ * @returns `null` to request the property be deleted.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const deleteProperty = function (_value: unknown): number | null | undefined {
+ return null;
+};
+
/**
* Move a property from one location to another.
* @param obj The configuration object in which the property resides.
@@ -156,15 +221,31 @@ export const moveConfigValue = (
obj: RawFrigateCardConfig,
oldPath: string,
newPath: string,
- transform?: (valueIn: unknown) => unknown,
+ options?: {
+ transform?: (valueIn: unknown) => unknown;
+ keepOriginal?: boolean;
+ },
): boolean => {
- let value = getConfigValue(obj, oldPath);
- if (transform) {
- value = transform(value);
+ const inValue = getConfigValue(obj, oldPath);
+ if (inValue === undefined) {
+ return false;
}
- if (typeof value !== 'undefined') {
- deleteConfigValue(obj, oldPath);
- setConfigValue(obj, newPath, value);
+ const outValue = options?.transform ? options.transform(inValue) : inValue;
+ if (oldPath === newPath && isEqual(inValue, outValue)) {
+ return false;
+ }
+ if (outValue === null) {
+ if (!options?.keepOriginal) {
+ deleteConfigValue(obj, oldPath);
+ return true;
+ }
+ return false;
+ }
+ if (outValue !== undefined) {
+ if (!options?.keepOriginal) {
+ deleteConfigValue(obj, oldPath);
+ }
+ setConfigValue(obj, newPath, outValue);
return true;
}
return false;
@@ -190,37 +271,13 @@ export const getArrayConfigPath = (path: string, index: number): string => {
const upgradeMoveTo = function (
oldPath: string,
newPath: string,
- transform?: (valueIn: unknown) => unknown,
+ options?: {
+ transform?: (valueIn: unknown) => unknown;
+ keepOriginal?: boolean;
+ },
): (obj: RawFrigateCardConfig) => boolean {
return function (obj: RawFrigateCardConfig): boolean {
- return moveConfigValue(obj, oldPath, newPath, transform);
- };
-};
-
-/**
- * Upgrade a property by changing it if it is present.
- * @param path The property path.
- * @param transform A callback that transforms the old value to the new value,
- * if undefined is returned the property is removed.
- * @returns `true` if the configuration was modified.
- */
-const upgradeChangeIfPresent = function (
- path: string,
- transform: (valueIn: unknown) => unknown,
-): (obj: RawFrigateCardConfig) => boolean {
- return function (obj: RawFrigateCardConfig): boolean {
- const oldValue = getConfigValue(obj, path);
- if (oldValue !== undefined) {
- const newValue = transform(oldValue);
- if (newValue === undefined) {
- deleteConfigValue(obj, path);
- return true;
- } else if (newValue !== oldValue) {
- setConfigValue(obj, path, newValue);
- return true;
- }
- }
- return false;
+ return moveConfigValue(obj, oldPath, newPath, options);
};
};
@@ -235,20 +292,49 @@ const upgradeChangeIfPresent = function (
const upgradeMoveToWithOverrides = function (
oldPath: string,
newPath: string,
- transform?: (valueIn: unknown) => unknown,
+ options?: {
+ transform?: (valueIn: unknown) => unknown;
+ keepOriginal?: boolean;
+ },
): (obj: RawFrigateCardConfig) => boolean {
return function (obj: RawFrigateCardConfig): boolean {
- let modified = upgradeMoveTo(oldPath, newPath, transform)(obj);
+ let modified = upgradeMoveTo(oldPath, newPath, options)(obj);
modified =
upgradeArrayValue(
CONF_OVERRIDES,
- upgradeMoveTo(oldPath, newPath, transform),
+ upgradeMoveTo(oldPath, newPath, options),
(obj) => obj.overrides as RawFrigateCardConfig | undefined,
)(obj) || modified;
return modified;
};
};
+/**
+ * Upgrade a property in place with overrides.
+ * @param path The old property path.
+ * @param transform An optional transform for the value.
+ * @returns A function that returns `true` if the configuration was modified.
+ */
+const upgradeWithOverrides = function (
+ path: string,
+ transform: (valueIn: unknown) => unknown,
+): (obj: RawFrigateCardConfig) => boolean {
+ return upgradeMoveToWithOverrides(path, path, { transform: transform });
+};
+
+/**
+ * Upgrade a property in place without overrides.
+ * @param path The old property path.
+ * @param transform An optional transform for the value.
+ * @returns A function that returns `true` if the configuration was modified.
+ */
+const upgrade = function (
+ path: string,
+ transform: (valueIn: unknown) => unknown,
+): (obj: RawFrigateCardConfig) => boolean {
+ return upgradeMoveTo(path, path, { transform: transform });
+};
+
/**
* Given a path to an array, apply an upgrade to each object in the array.
* @param arrayPath The path to the array to upgrade.
@@ -279,8 +365,7 @@ const upgradeArrayValue = function (
/**
* Upgrade from a singular camera model to multiple.
- * @param key A string key.
- * @returns A safe key.
+ * @returns An upgrade function.
*/
const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => {
return function (obj: RawFrigateCardConfig): boolean {
@@ -294,11 +379,11 @@ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) =>
const imports = {
camera_entity: CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
- 'frigate.camera_name': CONF_CAMERAS_ARRAY_CAMERA_NAME,
- 'frigate.client_id': CONF_CAMERAS_ARRAY_CLIENT_ID,
- 'frigate.label': CONF_CAMERAS_ARRAY_LABEL,
- 'frigate.url': CONF_CAMERAS_ARRAY_URL,
- 'frigate.zone': CONF_CAMERAS_ARRAY_ZONE,
+ 'frigate.camera_name': 'cameras.#.camera_name',
+ 'frigate.client_id': 'cameras.#.client_id',
+ 'frigate.label': 'cameras.#.label',
+ 'frigate.url': 'cameras.#.frigate_url',
+ 'frigate.zone': 'cameras.#.zone',
'live.webrtc.entity': `cameras.#.webrtc.entity`,
'live.webrtc.url': `cameras.#.webrtc.url`,
'live.provider': CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
@@ -311,6 +396,70 @@ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) =>
};
};
+/**
+ * Upgrade from a menu-mode to a style & position.
+ * @returns An upgrade function.
+ */
+const upgradeMenuModeToStyleAndPosition = (): ((
+ obj: RawFrigateCardConfig,
+) => boolean) => {
+ return function (obj: RawFrigateCardConfig): boolean {
+ let modified = false;
+
+ // Change the 'start' of the mode into a style.
+ modified =
+ upgradeMoveToWithOverrides('menu.mode', CONF_MENU_STYLE, {
+ transform: (mode: unknown): string | undefined => {
+ if (typeof mode === 'string') {
+ const result = mode.match(/^(hover|hidden|overlay|above|below|none)/);
+ if (result) {
+ switch (result[1]) {
+ case 'hover':
+ case 'hidden':
+ case 'overlay':
+ case 'none':
+ return result[1];
+ case 'above':
+ case 'below':
+ return 'outside';
+ }
+ }
+ }
+ return undefined;
+ },
+ keepOriginal: true,
+ })(obj) || modified;
+
+ // Change the 'end' of the mode into a position.
+ modified =
+ upgradeMoveToWithOverrides('menu.mode', CONF_MENU_POSITION, {
+ transform: (mode: unknown): string | undefined => {
+ if (typeof mode === 'string') {
+ const result = mode.match(/(above|below|left|right|top|bottom)$/);
+ if (result) {
+ switch (result[1]) {
+ case 'left':
+ case 'right':
+ case 'top':
+ case 'bottom':
+ return result[1];
+ case 'above':
+ return 'top';
+ case 'below':
+ return 'bottom';
+ }
+ }
+ }
+ return undefined;
+ },
+ keepOriginal: true,
+ })(obj) || modified;
+
+ // Delete the old `menu.mode` .
+ return upgradeWithOverrides('menu.mode', deleteProperty)(obj) || modified;
+ };
+};
+
/**
* Upgrade from a condition on the menu (to allow rendering) to a menu mode
* override instead.
@@ -345,6 +494,56 @@ const upgradeMenuConditionToMenuOverride = (): ((
};
};
+/**
+ * Transform a menu button from a boolean to a priority.
+ * @param value The boolean true/false for show/hide the switch.
+ * @returns A priority value.
+ */
+const menuButtonBooleanToObject = function (
+ value: unknown,
+): { enabled: boolean } | null | undefined {
+ if (typeof value === 'object') {
+ return undefined;
+ }
+ // If it's not a boolean remove it.
+ if (typeof value !== 'boolean') {
+ return null;
+ }
+ return { enabled: value };
+};
+
+/**
+ * Upgrade from a show_controls key to individual favorite/timeline keys.
+ * @returns An upgrade function.
+ */
+const upgradeThumbnailShowControlsToIndividualControls = (
+ thumbnailsBasePath: string,
+): ((obj: RawFrigateCardConfig) => boolean) => {
+ const thumbnailsShowControlsPath = `${thumbnailsBasePath}.show_controls`;
+
+ return function (obj: RawFrigateCardConfig): boolean {
+ let modified = false;
+ modified =
+ upgradeMoveToWithOverrides(
+ thumbnailsShowControlsPath,
+ `${thumbnailsBasePath}.show_favorite_control`,
+ { keepOriginal: true },
+ )(obj) || modified;
+
+ modified =
+ upgradeMoveToWithOverrides(
+ thumbnailsShowControlsPath,
+ `${thumbnailsBasePath}.show_timeline_control`,
+ { keepOriginal: true },
+ )(obj) || modified;
+
+ // Delete the old `show_controls`.
+ return (
+ upgradeWithOverrides(thumbnailsShowControlsPath, deleteProperty)(obj) || modified
+ );
+ };
+};
+
const UPGRADES = [
// v1.2.1 -> v2.0.0
upgradeMoveTo('frigate_url', 'frigate.url'),
@@ -358,12 +557,12 @@ const UPGRADES = [
upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD),
upgradeMoveTo('webrtc', 'live.webrtc'),
upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'),
- upgradeMoveTo('controls.nextprev', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE),
- upgradeMoveTo('controls.nextprev_size', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE),
- upgradeMoveTo('menu_mode', CONF_MENU_MODE),
+ upgradeMoveTo('controls.nextprev', 'event_viewer.controls.next_previous.style'),
+ upgradeMoveTo('controls.nextprev_size', 'event_viewer.controls.next_previous.size'),
+ upgradeMoveTo('menu_mode', 'menu.mode'),
upgradeMoveTo('menu_buttons', 'menu.buttons'),
upgradeMoveTo('menu_button_size', CONF_MENU_BUTTON_SIZE),
- upgradeMoveTo('image', 'image.src', isNotObject),
+ upgradeMoveTo('image', 'image.src', { transform: isNotObject }),
// v2.0.0 -> v2.1.0
upgradeMoveTo('update_entities', CONF_VIEW_UPDATE_ENTITIES),
@@ -371,17 +570,76 @@ const UPGRADES = [
// v2.1.0 -> v3.0.0-rc.1
upgradeToMultipleCameras(),
upgradeMenuConditionToMenuOverride(),
- upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, toNumberOrIgnore),
- upgradeMoveTo('event_viewer.autoplay_clip', CONF_EVENT_VIEWER_AUTO_PLAY),
+ upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, {
+ transform: toNumberOrIgnore,
+ }),
+ upgradeMoveTo('event_viewer.autoplay_clip', 'event_viewer.auto_play'),
// v3.0.0-rc.1 -> v3.0.0-rc.2
upgradeArrayValue(
CONF_CAMERAS,
- upgradeChangeIfPresent('live_provider', (val) =>
+ upgradeWithOverrides('live_provider', (val) =>
val === 'frigate' ? 'ha' : val === 'webrtc' ? 'webrtc-card' : val,
),
),
upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('webrtc', 'webrtc_card')),
upgradeMoveToWithOverrides('live.webrtc', CONF_LIVE_WEBRTC_CARD),
upgradeMoveToWithOverrides('image.src', CONF_IMAGE_URL),
+
+ // v3.0.0 -> v4.0.0-rc.1
+ upgradeWithOverrides(
+ CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
+ createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
+ ),
+ upgradeWithOverrides(
+ 'event_viewer.controls.thumbnails.size',
+ createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
+ ),
+ upgradeWithOverrides(
+ CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
+ createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
+ ),
+ upgradeWithOverrides(
+ 'event_viewer.controls.next_previous.size',
+ createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
+ ),
+ upgradeWithOverrides(
+ CONF_MENU_BUTTON_SIZE,
+ createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
+ ),
+ upgradeWithOverrides('event_gallery.min_columns', deleteProperty),
+ upgradeMenuModeToStyleAndPosition(),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_CAMERAS, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_LIVE, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_CLIPS, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_SNAPSHOTS, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_IMAGE, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_DOWNLOAD, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE_UI, menuButtonBooleanToObject),
+ upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonBooleanToObject),
+ upgrade(CONF_LIVE_LAZY_UNLOAD, (val) =>
+ typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
+ ),
+ upgrade(CONF_LIVE_AUTO_UNMUTE, (val) =>
+ typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
+ ),
+ upgrade('event_viewer.auto_play', (val) =>
+ typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
+ ),
+ upgrade('event_viewer.auto_unmute', (val) =>
+ typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
+ ),
+ upgradeMoveToWithOverrides('event_viewer', CONF_MEDIA_VIEWER),
+ upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('camera_name', 'frigate.camera_name')),
+ upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('client_id', 'frigate.client_id')),
+ upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('label', 'frigate.label')),
+ upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('frigate_url', 'frigate.url')),
+ upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('zone', 'frigate.zone')),
+
+ // v4.0.0-rc.1 -> v4.0.0-rc.3
+ upgradeThumbnailShowControlsToIndividualControls('event_gallery.controls.thumbnails'),
+ upgradeThumbnailShowControlsToIndividualControls('media_viewer.controls.thumbnails'),
+ upgradeThumbnailShowControlsToIndividualControls('live.controls.thumbnails'),
+ upgradeThumbnailShowControlsToIndividualControls('timeline.controls.thumbnails'),
];
diff --git a/src/const.ts b/src/const.ts
index 48b571a4..ca56f40e 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -1,58 +1,103 @@
-export const CARD_VERSION = '3.0.0' as const;
+export const CAMERA_BIRDSEYE = 'birdseye' as const;
export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const;
export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const;
export const CONF_CAMERAS = 'cameras' as const;
export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY =
`${CONF_CAMERAS}.#.camera_entity` as const;
-export const CONF_CAMERAS_ARRAY_CAMERA_NAME = `${CONF_CAMERAS}.#.camera_name` as const;
-export const CONF_CAMERAS_ARRAY_CLIENT_ID = `${CONF_CAMERAS}.#.client_id` as const;
-export const CONF_CAMERAS_ARRAY_LABEL = `${CONF_CAMERAS}.#.label` as const;
-export const CONF_CAMERAS_ARRAY_URL = `${CONF_CAMERAS}.#.frigate_url` as const;
-export const CONF_CAMERAS_ARRAY_ZONE = `${CONF_CAMERAS}.#.zone` as const;
+export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME =
+ `${CONF_CAMERAS}.#.frigate.camera_name` as const;
+export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
+ `${CONF_CAMERAS}.#.frigate.client_id` as const;
+export const CONF_CAMERAS_ARRAY_FRIGATE_LABEL =
+ `${CONF_CAMERAS}.#.frigate.label` as const;
+export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const;
+export const CONF_CAMERAS_ARRAY_FRIGATE_ZONE = `${CONF_CAMERAS}.#.frigate.zone` as const;
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
`${CONF_CAMERAS}.#.webrtc_card.entity` as const;
-export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = `${CONF_CAMERAS}.#.webrtc_card.url` as const;
+export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
+ `${CONF_CAMERAS}.#.webrtc_card.url` as const;
export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER =
`${CONF_CAMERAS}.#.live_provider` as const;
+export const CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS =
+ `${CONF_CAMERAS}.#.dependencies.cameras` as const;
+export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
+ `${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
+export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
+ `${CONF_CAMERAS}.#.triggers.motion` as const;
+export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
+ `${CONF_CAMERAS}.#.triggers.occupancy` as const;
+export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
+ `${CONF_CAMERAS}.#.triggers.entities` as const;
export const CONF_VIEW = 'view' as const;
export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
+export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const;
export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const;
export const CONF_VIEW_TIMEOUT_SECONDS = `${CONF_VIEW}.timeout_seconds` as const;
export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const;
export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const;
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
+export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const;
+export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const;
+export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS =
+ `${CONF_VIEW_SCAN}.show_trigger_status` as const;
+export const CONF_VIEW_SCAN_UNTRIGGER_RESET =
+ `${CONF_VIEW_SCAN}.untrigger_reset` as const;
+export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS =
+ `${CONF_VIEW_SCAN}.untrigger_seconds` as const;
export const CONF_EVENT_GALLERY = 'event_gallery' as const;
-export const CONF_EVENT_GALLERY_MIN_COLUMNS =
- `${CONF_EVENT_GALLERY}.min_columns` as const;
+export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS =
+ `${CONF_EVENT_GALLERY}.controls.thumbnails.show_details` as const;
+export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
+ `${CONF_EVENT_GALLERY}.controls.thumbnails.show_favorite_control` as const;
+export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
+ `${CONF_EVENT_GALLERY}.controls.thumbnails.show_timeline_control` as const;
+export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE =
+ `${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const;
-export const CONF_EVENT_VIEWER = 'event_viewer' as const;
-export const CONF_EVENT_VIEWER_AUTO_PLAY = `${CONF_EVENT_VIEWER}.auto_play` as const;
-export const CONF_EVENT_VIEWER_AUTO_UNMUTE = `${CONF_EVENT_VIEWER}.auto_unmute` as const;
-export const CONF_EVENT_VIEWER_DRAGGABLE = `${CONF_EVENT_VIEWER}.draggable` as const;
-export const CONF_EVENT_VIEWER_LAZY_LOAD = `${CONF_EVENT_VIEWER}.lazy_load` as const;
-export const CONF_EVENT_VIEWER_TRANSITION_EFFECT =
- `${CONF_EVENT_VIEWER}.transition_effect` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
- `${CONF_EVENT_VIEWER}.controls.next_previous.style` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
- `${CONF_EVENT_VIEWER}.controls.next_previous.size` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE =
- `${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE =
- `${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE =
- `${CONF_EVENT_VIEWER}.controls.title.mode` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
- `${CONF_EVENT_VIEWER}.controls.title.duration_seconds` as const;
+export const CONF_MEDIA_VIEWER = 'media_viewer' as const;
+export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const;
+export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
+export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
+export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
+export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
+export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
+export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
+ `${CONF_MEDIA_VIEWER}.transition_effect` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
+ `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
+ `${CONF_MEDIA_VIEWER}.controls.next_previous.size` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
+ `${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
+ `${CONF_MEDIA_VIEWER}.controls.title.duration_seconds` as const;
+export const CONF_MEDIA_VIEWER_LAYOUT_FIT = `${CONF_MEDIA_VIEWER}.layout.fit` as const;
+export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X =
+ `${CONF_MEDIA_VIEWER}.layout.position.x` as const;
+export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y =
+ `${CONF_MEDIA_VIEWER}.layout.position.y` as const;
export const CONF_LIVE = 'live' as const;
+export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
+export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
+export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_LIVE}.controls.next_previous.style` as const;
@@ -64,35 +109,69 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
`${CONF_LIVE}.controls.thumbnails.mode` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
`${CONF_LIVE}.controls.thumbnails.size` as const;
+export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
+ `${CONF_LIVE}.controls.thumbnails.show_details` as const;
+export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
+ `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
+export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
+ `${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
`${CONF_LIVE}.controls.title.duration_seconds` as const;
+export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const;
+export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
+export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const;
export const CONF_LIVE_JSMPEG = `${CONF_LIVE}.jsmpeg` as const;
export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const;
export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const;
export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
+export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
+ `${CONF_LIVE}.show_image_during_load` as const;
export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const;
export const CONF_IMAGE = 'image' as const;
+export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
+export const CONF_IMAGE_LAYOUT_POSITION_X = `${CONF_IMAGE}.layout.position.x` as const;
+export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as const;
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
+export const CONF_TIMELINE = 'timeline' as const;
+export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
+export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
+ `${CONF_TIMELINE}.clustering_threshold` as const;
+export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
+export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
+export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
+ `${CONF_TIMELINE}.controls.thumbnails.mode` as const;
+export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
+ `${CONF_TIMELINE}.controls.thumbnails.size` as const;
+export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
+ `${CONF_TIMELINE}.controls.thumbnails.show_details` as const;
+export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
+ `${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
+export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
+ `${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const;
+
export const CONF_MENU = 'menu' as const;
+export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const;
+export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const;
+export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const;
+export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
+export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const;
+
+export const CONF_MENU_BUTTONS_CAMERAS = `${CONF_MENU}.buttons.cameras` as const;
+export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const;
+export const CONF_MENU_BUTTONS_DOWNLOAD = `${CONF_MENU}.buttons.download` as const;
export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const;
export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const;
-export const CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN =
- `${CONF_MENU}.buttons.fullscreen` as const;
-export const CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD =
- `${CONF_MENU}.buttons.download` as const;
-export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const;
-export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const;
-export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const;
+export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU}.buttons.fullscreen` as const;
export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const;
-export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
-export const CONF_MENU_MODE = `${CONF_MENU}.mode` as const;
+export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const;
+export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const;
export const CONF_DIMENSIONS = 'dimensions' as const;
export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const;
@@ -100,3 +179,6 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
`${CONF_DIMENSIONS}.aspect_ratio_mode` as const;
export const CONF_OVERRIDES = 'overrides' as const;
+
+// Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93
+export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
diff --git a/src/declarations.d.ts b/src/declarations.d.ts
index 59c79d0c..cd69265b 100644
--- a/src/declarations.d.ts
+++ b/src/declarations.d.ts
@@ -1,2 +1,6 @@
declare module '*.scss';
declare module '*.jpg';
+declare module 'view' {
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
+ interface ViewContext {}
+}
diff --git a/src/editor.ts b/src/editor.ts
index 30afefa1..b5f92116 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -1,77 +1,7 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
+import { fireEvent, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers';
+import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
-
-import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers';
-import { localize } from './localize/localize.js';
-import {
- frigateCardConfigDefaults,
- RawFrigateCardConfig,
- RawFrigateCardConfigArray,
-} from './types.js';
-
-import {
- CONF_CAMERAS,
- CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
- CONF_CAMERAS_ARRAY_CAMERA_NAME,
- CONF_CAMERAS_ARRAY_CLIENT_ID,
- CONF_CAMERAS_ARRAY_ICON,
- CONF_CAMERAS_ARRAY_ID,
- CONF_CAMERAS_ARRAY_LABEL,
- CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
- CONF_CAMERAS_ARRAY_TITLE,
- CONF_CAMERAS_ARRAY_URL,
- CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
- CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
- CONF_CAMERAS_ARRAY_ZONE,
- CONF_DIMENSIONS_ASPECT_RATIO,
- CONF_DIMENSIONS_ASPECT_RATIO_MODE,
- CONF_EVENT_GALLERY_MIN_COLUMNS,
- CONF_EVENT_VIEWER_AUTO_PLAY,
- CONF_EVENT_VIEWER_AUTO_UNMUTE,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE,
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
- CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
- CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
- CONF_EVENT_VIEWER_DRAGGABLE,
- CONF_EVENT_VIEWER_LAZY_LOAD,
- CONF_EVENT_VIEWER_TRANSITION_EFFECT,
- CONF_IMAGE_MODE,
- CONF_IMAGE_REFRESH_SECONDS,
- CONF_IMAGE_URL,
- CONF_LIVE_AUTO_UNMUTE,
- CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
- CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
- CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
- CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
- CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
- CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
- CONF_LIVE_CONTROLS_TITLE_MODE,
- CONF_LIVE_DRAGGABLE,
- CONF_LIVE_LAZY_LOAD,
- CONF_LIVE_LAZY_UNLOAD,
- CONF_LIVE_PRELOAD,
- CONF_LIVE_TRANSITION_EFFECT,
- CONF_MENU_BUTTONS_CLIPS,
- CONF_MENU_BUTTONS_FRIGATE,
- CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD,
- CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN,
- CONF_MENU_BUTTONS_FRIGATE_UI,
- CONF_MENU_BUTTONS_IMAGE,
- CONF_MENU_BUTTONS_LIVE,
- CONF_MENU_BUTTONS_SNAPSHOTS,
- CONF_MENU_BUTTON_SIZE,
- CONF_MENU_MODE,
- CONF_VIEW_CAMERA_SELECT,
- CONF_VIEW_DEFAULT,
- CONF_VIEW_TIMEOUT_SECONDS,
- CONF_VIEW_UPDATE_CYCLE_CAMERA,
- CONF_VIEW_UPDATE_FORCE,
- CONF_VIEW_UPDATE_SECONDS,
-} from './const.js';
-import { arrayMove, getEntityTitle, prettifyFrigateName } from './common.js';
+import { classMap } from 'lit/directives/class-map.js';
import {
copyConfig,
deleteConfigValue,
@@ -81,25 +11,155 @@ import {
setConfigValue,
upgradeConfig,
} from './config-mgmt.js';
-
+import {
+ CONF_CAMERAS,
+ CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
+ CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS,
+ CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
+ CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME,
+ CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID,
+ CONF_CAMERAS_ARRAY_FRIGATE_LABEL,
+ CONF_CAMERAS_ARRAY_FRIGATE_URL,
+ CONF_CAMERAS_ARRAY_FRIGATE_ZONE,
+ CONF_CAMERAS_ARRAY_ICON,
+ CONF_CAMERAS_ARRAY_ID,
+ CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
+ CONF_CAMERAS_ARRAY_TITLE,
+ CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
+ CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
+ CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
+ CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
+ CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
+ CONF_DIMENSIONS_ASPECT_RATIO,
+ CONF_DIMENSIONS_ASPECT_RATIO_MODE,
+ CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
+ CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
+ CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
+ CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE,
+ CONF_IMAGE_LAYOUT_FIT,
+ CONF_IMAGE_LAYOUT_POSITION_X,
+ CONF_IMAGE_LAYOUT_POSITION_Y,
+ CONF_IMAGE_MODE,
+ CONF_IMAGE_REFRESH_SECONDS,
+ CONF_IMAGE_URL,
+ CONF_LIVE_AUTO_MUTE,
+ CONF_LIVE_AUTO_PAUSE,
+ CONF_LIVE_AUTO_PLAY,
+ CONF_LIVE_AUTO_UNMUTE,
+ CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
+ CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
+ CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
+ CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
+ CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
+ CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
+ CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
+ CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
+ CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
+ CONF_LIVE_CONTROLS_TITLE_MODE,
+ CONF_LIVE_DRAGGABLE,
+ CONF_LIVE_LAYOUT_FIT,
+ CONF_LIVE_LAYOUT_POSITION_X,
+ CONF_LIVE_LAYOUT_POSITION_Y,
+ CONF_LIVE_LAZY_LOAD,
+ CONF_LIVE_LAZY_UNLOAD,
+ CONF_LIVE_PRELOAD,
+ CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
+ CONF_LIVE_TRANSITION_EFFECT,
+ CONF_MEDIA_VIEWER_AUTO_MUTE,
+ CONF_MEDIA_VIEWER_AUTO_PAUSE,
+ CONF_MEDIA_VIEWER_AUTO_PLAY,
+ CONF_MEDIA_VIEWER_AUTO_UNMUTE,
+ CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
+ CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
+ CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
+ CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
+ CONF_MEDIA_VIEWER_DRAGGABLE,
+ CONF_MEDIA_VIEWER_LAYOUT_FIT,
+ CONF_MEDIA_VIEWER_LAYOUT_POSITION_X,
+ CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y,
+ CONF_MEDIA_VIEWER_LAZY_LOAD,
+ CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
+ CONF_MENU_ALIGNMENT,
+ CONF_MENU_BUTTONS,
+ CONF_MENU_BUTTON_SIZE,
+ CONF_MENU_POSITION,
+ CONF_MENU_STYLE,
+ CONF_TIMELINE_CLUSTERING_THRESHOLD,
+ CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
+ CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
+ CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
+ CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
+ CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
+ CONF_TIMELINE_MEDIA,
+ CONF_TIMELINE_SHOW_RECORDINGS,
+ CONF_TIMELINE_WINDOW_SECONDS,
+ CONF_VIEW_CAMERA_SELECT,
+ CONF_VIEW_DARK_MODE,
+ CONF_VIEW_DEFAULT,
+ CONF_VIEW_SCAN,
+ CONF_VIEW_SCAN_ENABLED,
+ CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
+ CONF_VIEW_SCAN_UNTRIGGER_RESET,
+ CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
+ CONF_VIEW_TIMEOUT_SECONDS,
+ CONF_VIEW_UPDATE_CYCLE_CAMERA,
+ CONF_VIEW_UPDATE_FORCE,
+ CONF_VIEW_UPDATE_SECONDS,
+} from './const.js';
+import { localize } from './localize/localize.js';
import frigate_card_editor_style from './scss/editor.scss';
+import {
+ BUTTON_SIZE_MIN,
+ frigateCardConfigDefaults,
+ FRIGATE_MENU_PRIORITY_MAX,
+ RawFrigateCardConfig,
+ RawFrigateCardConfigArray,
+ THUMBNAIL_WIDTH_MAX,
+ THUMBNAIL_WIDTH_MIN,
+} from './types.js';
+import { arrayMove } from './utils/basic.js';
+import { getCameraID, getCameraTitle } from './utils/camera.js';
+import { FRIGATE_ICON_SVG_PATH } from './utils/frigate.js';
+import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha';
+
+const MENU_BUTTONS = 'buttons';
+const MENU_CAMERAS = 'cameras';
+const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
+const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
+const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
+const MENU_CAMERAS_WEBRTC = 'cameras.webrtc';
+const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls';
+const MENU_IMAGE_LAYOUT = 'image.layout';
+const MENU_LIVE_CONTROLS = 'live.controls';
+const MENU_LIVE_LAYOUT = 'live.layout';
+const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
+const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
+const MENU_TIMELINE_CONTROLS = 'timeline.controls';
+const MENU_OPTIONS = 'options';
+const MENU_VIEW_SCAN = 'scan';
interface EditorOptionsSet {
icon: string;
name: string;
secondary: string;
- show: boolean;
}
interface EditorOptions {
[setName: string]: EditorOptionsSet;
}
-interface EditorCameraTarget {
- cameraIndex: number;
+interface EditorSelectOption {
+ value: string;
+ label: string;
}
-interface EditorOptionSetTarget {
- optionSetName: string;
+interface EditorMenuTarget {
+ domain: string;
+ key: string | number;
}
const options: EditorOptions = {
@@ -107,55 +167,51 @@ const options: EditorOptions = {
icon: 'video',
name: localize('editor.cameras'),
secondary: localize('editor.cameras_secondary'),
- show: true,
},
view: {
icon: 'eye',
name: localize('editor.view'),
secondary: localize('editor.view_secondary'),
- show: false,
},
menu: {
icon: 'menu',
name: localize('editor.menu'),
secondary: localize('editor.menu_secondary'),
- show: false,
},
live: {
icon: 'cctv',
name: localize('editor.live'),
secondary: localize('editor.live_secondary'),
- show: false,
},
- event_viewer: {
+ media_viewer: {
icon: 'filmstrip',
- name: localize('editor.event_viewer'),
- secondary: localize('editor.event_viewer_secondary'),
- show: false,
+ name: localize('editor.media_viewer'),
+ secondary: localize('editor.media_viewer_secondary'),
},
event_gallery: {
icon: 'grid',
name: localize('editor.event_gallery'),
secondary: localize('editor.event_gallery_secondary'),
- show: false,
},
image: {
icon: 'image',
name: localize('editor.image'),
secondary: localize('editor.image_secondary'),
- show: false,
+ },
+ timeline: {
+ icon: 'chart-gantt',
+ name: localize('editor.timeline'),
+ secondary: localize('editor.timeline_secondary'),
},
dimensions: {
icon: 'aspect-ratio',
name: localize('editor.dimensions'),
secondary: localize('editor.dimensions_secondary'),
- show: false,
},
overrides: {
icon: 'file-replace',
name: localize('editor.overrides'),
secondary: localize('editor.overrides_secondary'),
- show: false,
},
};
@@ -163,14 +219,13 @@ const options: EditorOptions = {
export class FrigateCardEditor extends LitElement implements LovelaceCardEditor {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() protected _config?: RawFrigateCardConfig;
- @state() protected _helpers?: any;
protected _initialized = false;
protected _configUpgradeable = false;
- @property({ attribute: false })
- protected _expandedCameraIndex: number | null = null;
+ @state()
+ protected _expandedMenus: Record = {};
- protected _viewModes = [
+ protected _viewModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'live', label: localize('config.view.views.live') },
{ value: 'clips', label: localize('config.view.views.clips') },
@@ -178,49 +233,56 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'clip', label: localize('config.view.views.clip') },
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
{ value: 'image', label: localize('config.view.views.image') },
+ { value: 'timeline', label: localize('config.view.views.timeline') },
];
- protected _cameraSelectViewModes = [
+ protected _cameraSelectViewModes: EditorSelectOption[] = [
...this._viewModes,
{ value: 'current', label: localize('config.view.views.current') },
];
- protected _menuModes = [
+ protected _menuStyles: EditorSelectOption[] = [
{ value: '', label: '' },
- { value: 'none', label: localize('config.menu.modes.none') },
- { value: 'hidden-top', label: localize('config.menu.modes.hidden-top') },
- { value: 'hidden-left', label: localize('config.menu.modes.hidden-left') },
- { value: 'hidden-bottom', label: localize('config.menu.modes.hidden-bottom') },
- { value: 'hidden-right', label: localize('config.menu.modes.hidden-right') },
- { value: 'overlay-top', label: localize('config.menu.modes.overlay-top') },
- { value: 'overlay-left', label: localize('config.menu.modes.overlay-left') },
- { value: 'overlay-bottom', label: localize('config.menu.modes.overlay-bottom') },
- { value: 'overlay-right', label: localize('config.menu.modes.overlay-right') },
- { value: 'hover-top', label: localize('config.menu.modes.hover-top') },
- { value: 'hover-left', label: localize('config.menu.modes.hover-left') },
- { value: 'hover-bottom', label: localize('config.menu.modes.hover-bottom') },
- { value: 'hover-right', label: localize('config.menu.modes.hover-right') },
- { value: 'above', label: localize('config.menu.modes.above') },
- { value: 'below', label: localize('config.menu.modes.below') },
+ { value: 'none', label: localize('config.menu.styles.none') },
+ { value: 'hidden', label: localize('config.menu.styles.hidden') },
+ { value: 'overlay', label: localize('config.menu.styles.overlay') },
+ { value: 'hover', label: localize('config.menu.styles.hover') },
+ { value: 'outside', label: localize('config.menu.styles.outside') },
];
- protected _eventViewerNextPreviousControlStyles = [
+ protected _menuPositions: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'left', label: localize('config.menu.positions.left') },
+ { value: 'right', label: localize('config.menu.positions.right') },
+ { value: 'top', label: localize('config.menu.positions.top') },
+ { value: 'bottom', label: localize('config.menu.positions.bottom') },
+ ];
+
+ protected _menuAlignments: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'left', label: localize('config.menu.alignments.left') },
+ { value: 'right', label: localize('config.menu.alignments.right') },
+ { value: 'top', label: localize('config.menu.alignments.top') },
+ { value: 'bottom', label: localize('config.menu.alignments.bottom') },
+ ];
+
+ protected _eventViewerNextPreviousControlStyles: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'thumbnails',
- label: localize('config.event_viewer.controls.next_previous.styles.thumbnails'),
+ label: localize('config.media_viewer.controls.next_previous.styles.thumbnails'),
},
{
value: 'chevrons',
- label: localize('config.event_viewer.controls.next_previous.styles.chevrons'),
+ label: localize('config.media_viewer.controls.next_previous.styles.chevrons'),
},
{
value: 'none',
- label: localize('config.event_viewer.controls.next_previous.styles.none'),
+ label: localize('config.media_viewer.controls.next_previous.styles.none'),
},
];
- protected _liveNextPreviousControlStyles = [
+ protected _liveNextPreviousControlStyles: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'chevrons',
@@ -233,7 +295,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'none', label: localize('config.live.controls.next_previous.styles.none') },
];
- protected _aspectRatioModes = [
+ protected _aspectRatioModes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'dynamic',
@@ -246,23 +308,31 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
},
];
- protected _thumbnailModes = [
+ protected _thumbnailModes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'none',
- label: localize('config.event_viewer.controls.thumbnails.modes.none'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.none'),
},
{
value: 'above',
- label: localize('config.event_viewer.controls.thumbnails.modes.above'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.above'),
},
{
value: 'below',
- label: localize('config.event_viewer.controls.thumbnails.modes.below'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.below'),
+ },
+ {
+ value: 'left',
+ label: localize('config.media_viewer.controls.thumbnails.modes.left'),
+ },
+ {
+ value: 'right',
+ label: localize('config.media_viewer.controls.thumbnails.modes.right'),
},
];
- protected _thumbnailMedias = [
+ protected _thumbnailMedias: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'clips', label: localize('config.live.controls.thumbnails.medias.clips') },
{
@@ -271,40 +341,86 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
},
];
- protected _titleModes = [
+ protected _titleModes: EditorSelectOption[] = [
{ value: '', label: '' },
- { value: 'none', label: localize('config.event_viewer.controls.title.modes.none') },
+ { value: 'none', label: localize('config.media_viewer.controls.title.modes.none') },
{
value: 'popup-top-left',
- label: localize('config.event_viewer.controls.title.modes.popup-top-left'),
+ label: localize('config.media_viewer.controls.title.modes.popup-top-left'),
},
{
value: 'popup-top-right',
- label: localize('config.event_viewer.controls.title.modes.popup-top-right'),
+ label: localize('config.media_viewer.controls.title.modes.popup-top-right'),
},
{
value: 'popup-bottom-left',
- label: localize('config.event_viewer.controls.title.modes.popup-bottom-left'),
+ label: localize('config.media_viewer.controls.title.modes.popup-bottom-left'),
},
{
value: 'popup-bottom-right',
- label: localize('config.event_viewer.controls.title.modes.popup-bottom-right'),
+ label: localize('config.media_viewer.controls.title.modes.popup-bottom-right'),
},
];
- protected _transitionEffects = [
+ protected _transitionEffects: EditorSelectOption[] = [
{ value: '', label: '' },
- { value: 'none', label: localize('config.event_viewer.transition_effects.none') },
- { value: 'slide', label: localize('config.event_viewer.transition_effects.slide') },
+ { value: 'none', label: localize('config.media_viewer.transition_effects.none') },
+ { value: 'slide', label: localize('config.media_viewer.transition_effects.slide') },
];
- protected _imageModes = [
+ protected _imageModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'camera', label: localize('config.image.modes.camera') },
{ value: 'screensaver', label: localize('config.image.modes.screensaver') },
{ value: 'url', label: localize('config.image.modes.url') },
];
+ protected _timelineMediaTypes: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'all', label: localize('config.timeline.medias.all') },
+ { value: 'clips', label: localize('config.timeline.medias.clips') },
+ { value: 'snapshots', label: localize('config.timeline.medias.snapshots') },
+ ];
+
+ protected _darkModes: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'on', label: localize('config.view.dark_modes.on') },
+ { value: 'off', label: localize('config.view.dark_modes.off') },
+ { value: 'auto', label: localize('config.view.dark_modes.auto') },
+ ];
+
+ protected _mediaActionNegativeConditions: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'all', label: localize('config.common.media_action_conditions.all') },
+ {
+ value: 'unselected',
+ label: localize('config.common.media_action_conditions.unselected'),
+ },
+ { value: 'hidden', label: localize('config.common.media_action_conditions.hidden') },
+ { value: 'never', label: localize('config.common.media_action_conditions.never') },
+ ];
+
+ protected _mediaActionPositiveConditions: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'all', label: localize('config.common.media_action_conditions.all') },
+ {
+ value: 'selected',
+ label: localize('config.common.media_action_conditions.selected'),
+ },
+ {
+ value: 'visible',
+ label: localize('config.common.media_action_conditions.visible'),
+ },
+ { value: 'never', label: localize('config.common.media_action_conditions.never') },
+ ];
+
+ protected _layoutFits: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'contain', label: localize('config.common.layout.fits.contain') },
+ { value: 'cover', label: localize('config.common.layout.fits.cover') },
+ { value: 'fill', label: localize('config.common.layout.fits.fill') },
+ ];
+
public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be
// partially or completely invalid. It's more useful to have a partially
@@ -312,29 +428,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
// such, RawFrigateCardConfig is used as the type.
this._config = config;
this._configUpgradeable = isConfigUpgradeable(config);
- this.loadCardHelpers();
}
- protected shouldUpdate(): boolean {
+ /**
+ * Called before each update.
+ */
+ protected willUpdate(): void {
if (!this._initialized) {
- this._initialize();
+ sideLoadHomeAssistantElements().then((success) => {
+ if (success) {
+ this._initialized = true;
+ }
+ });
}
-
- return true;
- }
-
- protected _getEntities(domain: string): string[] {
- if (!this.hass) {
- return [];
- }
- const entities = Object.keys(this.hass.states).filter(
- (eid) => eid.substr(0, eid.indexOf('.')) === domain,
- );
- entities.sort();
-
- // Add a blank entry to unset a selection.
- entities.unshift('');
- return entities;
}
/**
@@ -348,8 +454,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return html`
@@ -405,11 +512,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
* Render an option/"select" selector.
* @param configPath The configuration path to set/read.
* @param options The options to show in the selector.
+ * @param params Option parameters to control the selector.
* @returns A rendered template.
*/
protected _renderOptionSelector(
configPath: string,
options: string[] | { value: string; label: string }[],
+ params?: {
+ multiple?: boolean;
+ label?: string;
+ },
): TemplateResult | void {
if (!this._config) {
return;
@@ -418,8 +530,41 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return html`
this._valueChangedHandler(configPath, ev)}
+ >
+
+ `;
+ }
+
+ /**
+ * Render an icon selector.
+ * @param configPath The configuration path to set/read.
+ * @param params Optional parameters to control the selector.
+ * @returns A rendered template.
+ */
+ protected _renderIconSelector(
+ configPath: string,
+ params?: {
+ label?: string;
+ },
+ ): TemplateResult | void {
+ if (!this._config) {
+ return;
+ }
+
+ return html`
+ this._valueChangedHandler(configPath, ev)}
@@ -431,29 +576,30 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
/**
* Render a number slider.
* @param configPath Configuration path of the variable.
- * @param valueDefault The default value.
- * @param icon The icon to use on the slider.
- * @param min The minimum value.
- * @param max The maximum value.
+ * @param params Optional parameters to control the selector.
* @returns A rendered template.
*/
protected _renderNumberInput(
configPath: string,
- min?: number,
- max?: number,
+ params?: {
+ min?: number;
+ max?: number;
+ label?: string;
+ default?: number;
+ },
): TemplateResult | void {
if (!this._config) {
return;
}
const value = getConfigValue(this._config, configPath);
- const mode = max === undefined ? 'box' : 'slider';
+ const mode = params?.max === undefined ? 'box' : 'slider';
return html`
this._valueChangedHandler(configPath, ev)}
>
@@ -471,59 +617,208 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}
/**
- * Render a camera header.
- * @param cameraIndex The index of the camera to edit/add.
- * @param cameraConfig The configuration of the camera in question.
- * @param addNewCamera Whether or not this is a header to add a new camera.
- * @returns A rendered template.
+ * Get an editor title for the camera.
+ * @param cameraIndex The index of the camera in the cameras array.
+ * @param cameraConfig The raw camera configuration object.
+ * @returns A string title.
*/
- protected _renderCameraHeader(
+ protected _getEditorCameraTitle(
cameraIndex: number,
- cameraConfig?: RawFrigateCardConfig,
- addNewCamera?: boolean,
- ): TemplateResult {
+ cameraConfig: RawFrigateCardConfig,
+ ): string {
+ return (
+ getCameraTitle(this.hass, cameraConfig) ||
+ localize('editor.camera') + ' #' + cameraIndex
+ );
+ }
+
+ protected _renderViewScanMenu(): TemplateResult {
+ const submenuClasses = {
+ submenu: true,
+ selected: !!this._expandedMenus[MENU_VIEW_SCAN],
+ };
return html`
-