diff --git a/README.md b/README.md index 07d8695e..0b56faa6 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ menu: | Option | Default | Overridable | Description | | - | - | - | - | | `mode` | `hidden-top` | :white_check_mark: | The menu mode to show by default. See [menu modes](#menu-modes) below.| -| `button_size` | `40px` | :white_check_mark: | The size of the menu buttons [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `button_size` | 40 | :white_check_mark: | The size of the menu buttons in pixels. Must be >= `20`.| | `buttons` | | :white_check_mark: | Whether to show or hide built-in buttons. See below. | #### Menu Options: Buttons @@ -258,7 +258,7 @@ live: | Option | Default | Overridable | Description | | - | - | - | - | | `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| -| `size` | `100px` | :white_check_mark: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `size` | 100 | :white_check_mark: | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `175`. | | `show_details` | `false` | :white_check_mark: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| | `show_controls` | `true` | :white_check_mark: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.| | `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.| @@ -276,7 +276,7 @@ live: | Option | Default | Overridable | Description | | - | - | - | - | | `style` | `chevrons` | :white_check_mark: | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . | -| `size` | `48px` | :white_check_mark: | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `size` | 48 | :white_check_mark: | The size of the next/previous controls in pixels. Must be >= `20`. | @@ -328,7 +328,7 @@ event_viewer: | Option | Default | Overridable | Description | | - | - | - | - | | `style` | `thumbnails` | :heavy_multiplication_x: | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | -| `size` | `48px` | :heavy_multiplication_x: | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `size` | 48 | :heavy_multiplication_x: | The size of the next/previous controls in pixels. Must be >= `20`.| #### Event Viewer Controls: Thumbnails @@ -343,7 +343,7 @@ event_viewer: | Option | Default | Overridable | Description | | - | - | - | - | | `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| -| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel pixels. Must be >= `75` and <= `175`.| | `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| | `show_controls` | `true` | :heavy_multiplication_x: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.| @@ -374,7 +374,9 @@ event_gallery: | Option | Default | Overridable | Description | | - | - | - | - | -| `min_columns` | `5` | :heavy_multiplication_x: | The minimum number of columns to show in the gallery -- smaller values will render fewer but larger thumbnail columns. Thumbnails will never be stretched beyond their intrinsic size (typically `175px`). All available space (in normal mode, or fullscreen mode) will be occupied by the gallery, so more columns than `min_columns` will often be rendered if space allows for more full-sized thumbnails. For normal sized Lovelace cards (`492px` wide), this typically means there'll never be fewer than 3 columns rendered (as otherwise the thumbnails would need to stretch beyond their actual size). Must be `1 <= x <= 10`. | +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the event gallery in pixels. Must be >= `75` and <= `175`.| +| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_controls` | `true` | :heavy_multiplication_x: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.| | `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`). See [actions](#actions) below.| ### Image Options @@ -423,7 +425,7 @@ timeline: | Option | Default | Overridable | Description | | - | - | - | - | | `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| -| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `175`.| | `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| | `show_controls` | `true` | :heavy_multiplication_x: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.| diff --git a/package.json b/package.json index 91ddb3fd..65585378 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@cycjimmy/jsmpeg-player": "^5.1.1", "@egjs/hammerjs": "^2.0.17", "@lit-labs/task": "^1.1.1", - "@material/image-list": "^13.0.0", "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", "@types/bluebird": "^3.5.36", @@ -32,7 +31,7 @@ "keycharm": "^0.4.0", "lit": "^2.2.1", "lodash-es": "^4.17.21", - "moment": "^2.29.1", + "moment": "^2.29.2", "propagating-hammerjs": "^2.0.1", "quick-lru": "^6.1.0", "screenfull": "^6.0.1", diff --git a/src/components/gallery.ts b/src/components/gallery.ts index b83c8382..3ab4f931 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -1,22 +1,30 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + 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 { customElement, property } from 'lit/decorators.js'; import { CameraConfig, ExtendedHomeAssistant, GalleryConfig, - THUMBNAIL_WIDTH_MAX, frigateCardConfigDefaults, } from '../types.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; import { View } from '../view.js'; -import { localize } from '../localize/localize.js'; +import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { renderProgressIndicator } from './message.js'; import { stopEventFromActivatingCardWideActions } from '../common.js'; +import './thumbnail.js'; + import galleryStyle from '../scss/gallery.scss'; @customElement('frigate-card-gallery') @@ -77,7 +85,13 @@ export class FrigateCardGallery extends LitElement { * Get element styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(galleryStyle); + return css` + :host { + display: block; + width: 100%; + height: 100%; + } + `; } } @@ -94,9 +108,6 @@ export class FrigateCardGalleryCore extends LitElement { protected _resizeObserver: ResizeObserver; - @state() - protected _columns = frigateCardConfigDefaults.event_gallery.min_columns; - constructor() { super(); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); @@ -122,10 +133,16 @@ export class FrigateCardGalleryCore extends LitElement { * Handle gallery resize. */ protected _resizeHandler(): void { - this._columns = Math.max( - this.galleryConfig?.min_columns ?? - frigateCardConfigDefaults.event_gallery.min_columns, - Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX), + const thumbnailSize = + this.galleryConfig?.controls.thumbnails.size ?? + frigateCardConfigDefaults.event_gallery.controls.thumbnails.size; + this.style.setProperty( + '--frigate-card-gallery-columns', + String( + !this.galleryConfig?.controls.thumbnails.show_details + ? Math.round(this.clientWidth / thumbnailSize) + : Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN)), + ), ); } @@ -141,6 +158,26 @@ export class FrigateCardGalleryCore extends LitElement { ); } + /** + * 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'); + } + 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. @@ -156,93 +193,67 @@ export class FrigateCardGalleryCore extends LitElement { 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` `; + `; } /** diff --git a/src/components/menu.ts b/src/components/menu.ts index 51af65a6..b253c47c 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -60,7 +60,10 @@ export class FrigateCardMenu extends LitElement { 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); diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 6c37f651..ec9882ac 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -13,7 +13,7 @@ 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; } @@ -31,7 +31,7 @@ export class FrigateCardNextPreviousControl extends LitElement { public disabled = false; // Label that is used for ARIA support and as tooltip. - @property() label = ""; + @property() label = ''; protected render(): TemplateResult { if (this.disabled || !this._controlConfig || this._controlConfig.style == 'none') { @@ -50,18 +50,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` `; } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 669d70d7..ae05d5de 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -4,7 +4,6 @@ import { EmblaOptionsType } from 'embla-carousel'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { isEqual } from 'lodash-es'; import type { FrigateBrowseMediaSource, diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 346a24d5..4a4480a4 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -12,6 +12,44 @@ import { import { localize } from '../localize/localize.js'; import thumbnailStyle from '../scss/thumbnail.scss'; +import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; + +// The minimum width of a thumbnail with details enabled. +export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; + +@customElement('frigate-card-thumbnail-details') +export class FrigateCardThumbnailDetails 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`
+
${prettifyFrigateName(this.event.label)}
+
+ ${localize('event.start')}: + ${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')} +
+
+ ${localize('event.duration')}: + ${getEventDurationString(this.event)} +
+
+
+ ${score} +
`; + } + + /** + * Get element styles. + */ + static get styles(): CSSResult { + return unsafeCSS(thumbnailDetailsStyle); + } +} @customElement('frigate-card-thumbnail') export class FrigateCardThumbnail extends LitElement { @@ -21,9 +59,9 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: true, type: Boolean }) public controls = false; - @property({ attribute: true }) - set thumbnail_size(size: string) { - this.style.setProperty('--frigate-card-thumbnail-size', String(size)); + @property({ attribute: true, type: Number }) + set thumbnail_size(size: number) { + this.style.setProperty('--frigate-card-thumbnail-size', `${size}px`); } // ============================ @@ -33,7 +71,7 @@ export class FrigateCardThumbnail extends LitElement { protected view?: Readonly; @property({ attribute: false }) - public target?: FrigateBrowseMediaSource; + public target?: FrigateBrowseMediaSource | null; @property({ attribute: false }) public childIndex?: number; @@ -91,26 +129,9 @@ export class FrigateCardThumbnail extends LitElement { />` : ``} ${this.details && event - ? html`
-
-
${prettifyFrigateName(event.label)}
-
- - ${localize('event.start')}: - ${format(fromUnixTime(event.start_time), 'HH:mm:ss')} - -
-
- - ${localize('event.duration')}: - ${getEventDurationString(event)} - -
-
-
-
${(event.top_score * 100).toFixed(2) + '%'}
-
-
` + ? html`` : html``} ${this.controls ? html` 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. @@ -158,13 +219,21 @@ export const moveConfigValue = ( newPath: string, transform?: (valueIn: unknown) => unknown, ): 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') { + const outValue = transform ? transform(inValue) : inValue; + if (oldPath === newPath && isEqual(inValue, outValue)) { + return false; + } + if (outValue === null) { deleteConfigValue(obj, oldPath); - setConfigValue(obj, newPath, value); + return true; + } + if (outValue !== undefined) { + deleteConfigValue(obj, oldPath); + setConfigValue(obj, newPath, outValue); return true; } return false; @@ -197,33 +266,6 @@ const upgradeMoveTo = function ( }; }; -/** - * 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; - }; -}; - /** * Upgrade by moving a property from one location to another, and moving a * property specified in a top-level overrides object. @@ -249,6 +291,19 @@ const upgradeMoveToWithOverrides = function ( }; }; +/** + * 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); +}; + /** * Given a path to an array, apply an upgrade to each object in the array. * @param arrayPath The path to the array to upgrade. @@ -377,11 +432,37 @@ const UPGRADES = [ // 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( + CONF_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( + CONF_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 + ), ]; diff --git a/src/const.ts b/src/const.ts index 0bdef699..cd300e66 100644 --- a/src/const.ts +++ b/src/const.ts @@ -29,8 +29,12 @@ 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_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_CONTROLS = +`${CONF_EVENT_GALLERY}.controls.thumbnails.show_controls` 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; diff --git a/src/editor.ts b/src/editor.ts index f74e9a77..af83bfaa 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -5,9 +5,12 @@ import { customElement, property, state } from 'lit/decorators.js'; import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; import { localize } from './localize/localize.js'; import { - frigateCardConfigDefaults, + BUTTON_SIZE_MIN, RawFrigateCardConfig, RawFrigateCardConfigArray, + THUMBNAIL_WIDTH_MAX, + THUMBNAIL_WIDTH_MIN, + frigateCardConfigDefaults, } from './types.js'; import { @@ -26,7 +29,9 @@ import { CONF_CAMERAS_ARRAY_ZONE, CONF_DIMENSIONS_ASPECT_RATIO, CONF_DIMENSIONS_ASPECT_RATIO_MODE, - CONF_EVENT_GALLERY_MIN_COLUMNS, + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS, + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, CONF_EVENT_VIEWER_AUTO_PLAY, CONF_EVENT_VIEWER_AUTO_UNMUTE, CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, @@ -843,7 +848,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
${this._renderOptionSelector(CONF_MENU_MODE, this._menuModes)} - ${this._renderStringInput(CONF_MENU_BUTTON_SIZE)} + ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, BUTTON_SIZE_MIN)} ${this._renderSwitch( CONF_MENU_BUTTONS_FRIGATE, defaults.menu.buttons.frigate, @@ -900,7 +905,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, this._liveNextPreviousControlStyles, )} - ${this._renderStringInput(CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE)} + ${this._renderNumberInput( + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, + BUTTON_SIZE_MIN, + )} ${this._renderOptionSelector( CONF_LIVE_CONTROLS_THUMBNAILS_MODE, this._thumbnailModes, @@ -909,7 +917,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, this._thumbnailMedias, )} - ${this._renderStringInput(CONF_LIVE_CONTROLS_THUMBNAILS_SIZE)} + ${this._renderNumberInput( + CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + THUMBNAIL_WIDTH_MIN, + THUMBNAIL_WIDTH_MAX, + )} ${this._renderOptionSelector( CONF_LIVE_CONTROLS_TITLE_MODE, this._titleModes, @@ -937,7 +949,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSetHeader('event_gallery')} ${options.event_gallery.show ? html`
- ${this._renderNumberInput(CONF_EVENT_GALLERY_MIN_COLUMNS, 1, 10)} + ${this._renderNumberInput( + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, + THUMBNAIL_WIDTH_MIN, + THUMBNAIL_WIDTH_MAX, + )} + ${this._renderSwitch( + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + defaults.event_viewer.controls.thumbnails.show_details, + )} + ${this._renderSwitch( + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS, + defaults.event_viewer.controls.thumbnails.show_controls, + )}
` : ''} ${this._renderOptionSetHeader('event_viewer')} @@ -963,12 +987,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, this._eventViewerNextPreviousControlStyles, )} - ${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE)} + ${this._renderNumberInput( + CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, + BUTTON_SIZE_MIN, + )} ${this._renderOptionSelector( CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE, this._thumbnailModes, )} - ${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE)} + ${this._renderNumberInput( + CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE, + THUMBNAIL_WIDTH_MIN, + THUMBNAIL_WIDTH_MAX, + )} ${this._renderSwitch( CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, defaults.event_viewer.controls.thumbnails.show_details, @@ -1005,12 +1036,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)} ${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)} - ${this._renderOptionSelector(CONF_TIMELINE_MEDIA, this._timelineMediaTypes)} + ${this._renderOptionSelector( + CONF_TIMELINE_MEDIA, + this._timelineMediaTypes, + )} ${this._renderOptionSelector( CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, this._thumbnailModes, )} - ${this._renderStringInput(CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE)} + ${this._renderNumberInput( + CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, + THUMBNAIL_WIDTH_MIN, + THUMBNAIL_WIDTH_MAX, + )} ${this._renderSwitch( CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, defaults.timeline.controls.thumbnails.show_details, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1813fa37..71fc11fb 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -51,7 +51,13 @@ "update_seconds": "Refresh default view every X seconds (0=never)" }, "event_gallery": { - "min_columns": "Minimum number of columns" + "controls": { + "thumbnails": { + "size": "Event Gallery thumbnails size in pixels", + "show_details": "Show event details with thumbnails", + "show_controls": "Show event controls with thumbnails" + } + } }, "event_viewer": { "auto_play": "Automatically play media", @@ -66,7 +72,7 @@ "controls": { "next_previous": { "style": "Event Viewer next & previous control style", - "size": "Event Viewer next & previous control size (e.g. '48px')", + "size": "Event Viewer next & previous control size in pixels", "styles": { "thumbnails": "Thumbnails", "chevrons": "Chevrons", @@ -75,7 +81,7 @@ }, "thumbnails": { "mode": "Event Viewer thumbnails mode", - "size": "Event Viewer thumbnails size (e.g. '100px')", + "size": "Event Viewer thumbnails size in pixels", "show_details": "Show event details with thumbnails", "show_controls": "Show event controls with thumbnails", "modes": { @@ -109,7 +115,7 @@ "controls": { "next_previous": { "style": "Live view next & previous control style", - "size": "Live view next & previous control size (e.g. '48px')", + "size": "Live view next & previous control size in pixels", "styles": { "chevrons": "Chevrons", "icons": "Icons", @@ -118,7 +124,7 @@ }, "thumbnails": { "mode": "Live thumbnails mode", - "size": "Live thumbnails size (e.g. '100px')", + "size": "Live thumbnails size in pixels", "show_details": "Show event details with thumbnails", "show_controls": "Show event controls with thumbnails", "media": "Whether to show thumbnails of clips or snapshots", @@ -169,7 +175,7 @@ "above": "Above", "below": "Below" }, - "button_size": "Menu button size (e.g. '40px')" + "button_size": "Menu button size in pixels" }, "timeline": { "window_seconds": "The default length of the timeline view in seconds", @@ -183,7 +189,7 @@ "controls": { "thumbnails": { "mode": "Timeline thumbnails mode", - "size": "Timeline thumbnails size (e.g. '100px')", + "size": "Timeline thumbnails size in pixels", "show_details": "Show event details with thumbnails", "show_controls": "Show event controls with thumbnails" } @@ -235,7 +241,8 @@ "event": { "start": "Start", "duration": "Duration", - "in_progress": "In Progress" + "in_progress": "In Progress", + "score": "Score" }, "thumbnail": { "retain_indefinitely": "Event will be indefinitely retained", diff --git a/src/scss/const.scss b/src/scss/const.scss new file mode 100644 index 00000000..38cc2113 --- /dev/null +++ b/src/scss/const.scss @@ -0,0 +1,5 @@ +:host { + --frigate-card-thumbnail-size: 100px; + --frigate-card-thumbnail-size-max: 175px; + --frigate-card-thumbnail-details-width: calc(var(--frigate-card-thumbnail-size) + 200px); +} \ No newline at end of file diff --git a/src/scss/favorite.scss b/src/scss/favorite.scss deleted file mode 100644 index 0e3d2c32..00000000 --- a/src/scss/favorite.scss +++ /dev/null @@ -1,14 +0,0 @@ -ha-icon.favorite { - position: absolute; - color: gold; - background: rgba(0,0,0,0.2); - border-radius: 50%; -} - -ha-icon.timeline { - position: absolute; - color: var(--primary-color); - right: 0px; - background: rgba(0,0,0,0.2); - border-radius: 50%; -} diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index eb9d212e..2777fd32 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,9 +1,6 @@ -@use '@material/image-list/mdc-image-list'; -@use '@material/image-list'; -@use './favorite.scss'; +@use "const.scss"; :host { - display: block; width: 100%; height: 100%; overflow: auto; @@ -13,6 +10,14 @@ // Hide scrollbar: Firefox scrollbar-width: none; + + --frigate-card-gallery-gap: 3px; + --frigate-card-gallery-columns: 4; + + display: grid; + grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr)); + grid-auto-rows: var(--frigate-card-thumbnail-size); + grid-column-gap: var(--frigate-card-gallery-gap); } // Hide scrollbar for Chrome, Safari and Opera @@ -20,21 +25,7 @@ display: none; } -.frigate-card-gallery { - // Note: In fullscreen, number of columns is overwritten in Javascript based - // on dimensions. - @include image-list.standard-columns(4, 5px); - @include image-list.shape-radius(5px); -} - -.frigate-card-gallery .mdc-image-list__image { - transition: transform 0.2s linear; -} -.frigate-card-gallery .mdc-image-list__image:hover { - transform: scale(1.04); -} - -ha-card.frigate-card-gallery-folder { +ha-card { display: flex; justify-content: center; align-items: center; @@ -46,10 +37,11 @@ ha-card.frigate-card-gallery-folder { border-radius: 5px; background-color: var(--primary-background-color, black); padding: 10px; - height: 100%; line-height: 1; + overflow: hidden; } -ha-icon.favorite { - opacity: 0.8; +:host(:not([details])) ha-card, +:host(:not([details])) frigate-card-thumbnail { + aspect-ratio: 1 / 1; } diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index bf20b658..033d09fd 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,3 +1,5 @@ +@use "const.scss"; + :host { --frigate-card-carousel-thumbnail-opacity: 1.0; } @@ -18,4 +20,13 @@ } .embla__slide.slide-selected { opacity: 1.0; +} + +frigate-card-thumbnail[details] { + width: var(--frigate-card-thumbnail-details-width); + height: var(--frigate-card-thumbnail-size); +} +frigate-card-thumbnail:not([details]) { + width: var(--frigate-card-thumbnail-size); + height: var(--frigate-card-thumbnail-size); } \ No newline at end of file diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss new file mode 100644 index 00000000..df77a6c2 --- /dev/null +++ b/src/scss/thumbnail-details.scss @@ -0,0 +1,41 @@ +:host { + display: flex; + width: 100%; + margin-left: 5px; + padding: 8px; + color: var(--primary-text-color); + overflow: hidden; + column-gap: 5%; +} + +div.right, div.left { + display: flex; + flex-direction: column; + justify-content: center; +} + +div.left { + flex: 1; + + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: flex-start; + justify-content: center; + + overflow: hidden; + + column-gap: 5%; +} + +div.left div { + width: 100%; +} + +span.heading { + font-weight: bold; +} + +.larger { + font-size: 1.5rem; +} diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 2aa3ec48..5eaf92c4 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -1,15 +1,12 @@ -@use './favorite.scss'; +@use "const.scss"; :host { display: flex; flex-direction: row; + box-sizing: border-box; - --frigate-card-thumbnail-size: 100px; - - // Do not let the contain expand to fill more height than the height of the - // thumbnail. Without this the thumbnail carousel will allow the thumbnail to - // expand to the full height of the viewport on a vertical carousel. - max-height: var(--frigate-card-thumbnail-size); + // Ensure control icons are relative to the thumbnail. + position: relative; } :host([details]) { @@ -31,10 +28,11 @@ img { object-fit: cover; // Restrict images to a maximum of thumbnail size. - max-width: var(--frigate-card-thumbnail-size); - max-height: var(--frigate-card-thumbnail-size); - min-width: var(--frigate-card-thumbnail-size); - min-height: var(--frigate-card-thumbnail-size); + aspect-ratio: 1 / 1; + height: 100%; + + max-width: var(--frigate-card-thumbnail-size-max); + max-height: var(--frigate-card-thumbnail-size-max); transition: transform 0.2s linear; } @@ -42,27 +40,18 @@ img:hover { transform: scale(1.04); } -div.details { - display: flex; - - width: 200px; - margin-left: 5px; - padding: 8px; - color: var(--primary-text-color); -} -div.details div { - display: flex; - flex-direction: column; - justify-content: center; -} -div.details div.left { - flex: 1; +ha-icon.favorite { + position: absolute; + color: gold; + background: rgba(0, 0, 0, 0.2); + border-radius: 50%; + opacity: 0.8; } -div.larger { - font-size: 1.5rem; -} - -span.heading { - font-weight: bold; +ha-icon.timeline { + position: absolute; + color: var(--primary-color); + right: 0px; + background: rgba(0, 0, 0, 0.2); + border-radius: 50%; } diff --git a/src/types.ts b/src/types.ts index 73a5dfc4..df87f2cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,13 @@ import { z } from 'zod'; import { deepRemoveDefaults } from './zod-util'; +// The min allowed size of buttons. +export const BUTTON_SIZE_MIN = 20; + +// The min/max width thumbnail (Frigate returns a maximum of 175px). +export const THUMBNAIL_WIDTH_MAX = 175; +export const THUMBNAIL_WIDTH_MIN = 75; + declare global { interface HTMLElementTagNameMap { 'frigate-card-editor': LovelaceCardEditor; @@ -66,9 +73,6 @@ export type LiveProvider = typeof LIVE_PROVIDERS[number]; export class FrigateCardError extends Error {} -// The maximum width thumbnail Frigate returns -export const THUMBNAIL_WIDTH_MAX = 175; - /** * Action Types (for "Picture Elements" / Menu) */ @@ -442,7 +446,7 @@ export type ImageViewConfig = z.infer; const thumbnailsControlSchema = z.object({ mode: z.enum(['none', 'above', 'below', 'left', 'right']), - size: z.string().optional(), + size: z.number().min(THUMBNAIL_WIDTH_MIN).max(THUMBNAIL_WIDTH_MAX).optional(), show_details: z.boolean().optional(), show_controls: z.boolean().optional(), }); @@ -454,7 +458,7 @@ export type ThumbnailsControlConfig = z.infer; const nextPreviousControlConfigSchema = z.object({ style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']), - size: z.string(), + size: z.number().min(BUTTON_SIZE_MIN), }); export type NextPreviousControlConfig = z.infer; @@ -491,12 +495,12 @@ const liveConfigDefault = { transition_effect: 'slide' as const, controls: { next_previous: { - size: '48px', + size: 48, style: 'chevrons' as const, }, thumbnails: { media: 'clips' as const, - size: '100px', + size: 100, show_details: false, show_controls: true, mode: 'none' as const, @@ -617,7 +621,7 @@ const menuConfigDefault = { frigate_ui: true, fullscreen: true, }, - button_size: '40px', + button_size: 40, }; const menuConfigSchema = z @@ -637,7 +641,7 @@ const menuConfigSchema = z fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen), }) .default(menuConfigDefault.buttons), - button_size: z.string().default(menuConfigDefault.button_size), + button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), }) .default(menuConfigDefault); export type MenuConfig = z.infer; @@ -653,11 +657,11 @@ const viewerConfigDefault = { transition_effect: 'slide' as const, controls: { next_previous: { - size: '48px', + size: 48, style: 'thumbnails' as const, }, thumbnails: { - size: '100px', + size: 100, mode: 'none' as const, show_details: false, show_controls: true, @@ -672,7 +676,9 @@ const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.ex style: z .enum(['none', 'thumbnails', 'chevrons']) .default(viewerConfigDefault.controls.next_previous.style), - size: z.string().default(viewerConfigDefault.controls.next_previous.size), + size: nextPreviousControlConfigSchema.shape.size.default( + viewerConfigDefault.controls.next_previous.size, + ), }); export type ViewerNextPreviousControlConfig = z.infer< typeof viewerNextPreviousControlConfigSchema @@ -729,12 +735,36 @@ export type ViewerConfig = z.infer; * Event gallery configuration section (clips, snapshots). */ const galleryConfigDefault = { - min_columns: 5, + controls: { + thumbnails: { + size: 100, + show_details: false, + show_controls: false, + }, + }, }; const galleryConfigSchema = z .object({ - min_columns: z.number().min(1).max(10).default(galleryConfigDefault.min_columns), + controls: z + .object({ + thumbnails: thumbnailsControlSchema + // Gallery shows thumbnails "centrally" so no need for the mode. + .omit({ mode: true }) + .extend({ + size: thumbnailsControlSchema.shape.size.default( + galleryConfigDefault.controls.thumbnails.size, + ), + show_details: thumbnailsControlSchema.shape.show_details.default( + galleryConfigDefault.controls.thumbnails.show_details, + ), + show_controls: thumbnailsControlSchema.shape.show_controls.default( + galleryConfigDefault.controls.thumbnails.show_controls, + ), + }) + .default(galleryConfigDefault.controls.thumbnails), + }) + .default(galleryConfigDefault.controls), }) .merge(actionsSchema) .default(galleryConfigDefault); @@ -776,7 +806,7 @@ const timelineConfigDefault = { controls: { thumbnails: { mode: 'left' as const, - size: '100px' as const, + size: 100, show_details: true, show_controls: true, },