From 72eae2bf8859613f57fe705cc773da625d04df30 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 6 Mar 2024 23:08:32 -0800 Subject: [PATCH] Update dimensions and media layout options to be per camera. --- README.md | 161 +++++++------------- src/card-controller/style-manager.ts | 14 +- src/card.ts | 5 +- src/components/live/live.ts | 12 +- src/components/viewer.ts | 17 ++- src/config-mgmt.ts | 4 + src/config/types.ts | 52 ++++--- src/const.ts | 21 ++- src/editor.ts | 84 +++++----- src/localize/languages/en.json | 42 ++--- src/localize/languages/it.json | 37 ++--- src/localize/languages/pt-BR.json | 37 ++--- src/localize/languages/pt-PT.json | 37 ++--- src/scss/live-carousel.scss | 11 ++ src/scss/live-provider.scss | 2 +- src/utils/basic.ts | 23 +++ tests/card-controller/style-manager.test.ts | 20 +-- tests/config-mgmt.test.ts | 54 +++++++ tests/utils/basic.test.ts | 24 ++- vite.config.ts | 12 +- 20 files changed, 375 insertions(+), 294 deletions(-) diff --git a/README.md b/README.md index bb8359d3..88360996 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `triggers` | | :white_check_mark: | Define what should cause this camera to update/trigger. See [camera triggers](#camera-trigger-configuration) below. | | `webrtc_card` | | :white_check_mark: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `cast` | | :white_check_mark: | Configuration that controls how this camera is "casted" / sent to media players. See below. | +| `dimensions` | | :white_check_mark: | Controls the dimensions and layout for media from this camera. See below. | @@ -363,6 +364,39 @@ cameras: | `dashboard_path` | | :white_check_mark: | A required field that specifies the name of the dashboard to cast. You can see this name in your HA URL when you visit the dashboard. | | `view_path` | | :white_check_mark: | A required field that specifies view/"tab" on that dashboard to cast. This is the value you have specified in the `url` field of the view configuration on the dashboard. | +### Camera Dimensions Configuration + +The `dimensions` block configures the dimensions and layout of media from a given camera. + +```yaml +cameras: + - dimensions: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `aspect_ratio` | | :white_check_mark: | An optional aspect ratio for media from this camera which will be used in `live` or media viewer related views (e.g. `clip`, `snapshot` and `recording`). Format is the same as the parameter of the same name under the [dimensions block](#dimensions) (which controls dimensions for the whole card), e.g. `16 / 9`. | +| `layout` | | :white_check_mark: | How the media should be laid out *within* the camera dimensions. See below. | + +### Camera Dimensions Layout Configuration + +The `layout` block configures the dimensions and layout of media from a given camera. + +```yaml +cameras: + - dimensions: + layout: +``` + +This block is used to control the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions parameter](#dimensions) ). As the default behavior is to always expand to fit the media precisely, these options only make sense if the camera `dimensions.aspect_ratio` is set to a static value that forces a particular aspect ratio that does not match the camera media. + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `fit` | `contain` | :white_check_mark: | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the card is fully covered. If `fill`, the media is stretched to fill the card (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. | +| `position` | | :white_check_mark: | A dictionary that contains an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover`. This can be effectively used to "pan" the media around. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the card width (in which case `x` controls the position) or the media height is larger than the card height (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technical details and a visualization. | + +See [media layout examples](#media-layout-examples). + #### Camera IDs: Referring to cameras in card configuration @@ -533,7 +567,6 @@ See the [fully expanded live configuration example](#config-expanded-live) for h | `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. | | `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.| | `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. | -| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| | `microphone` | | :white_check_mark: | See [microphone](#microphone) below.| | `display` | | :white_check_mark: | See [display](#live-display) below.| @@ -721,7 +754,6 @@ See the [fully expanded Media viewer configuration example](#config-expanded-med | `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. | | `controls` | | :heavy_multiplication_x: | Configuration for the Media viewer controls. See below. | | `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.| -| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| #### Media Viewer Controls @@ -939,8 +971,6 @@ timeline: | `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| - - #### Timeline Seek Behavior The behavior of the timeline during seeking/dragging can be controlled by means of the icon on the bottom-right of the timeline. @@ -952,6 +982,8 @@ The behavior of the timeline during seeking/dragging can be controlled by means | camera-lock | Dragging the timeline will seek / select across all available media from the selected camera only, selecting the media item with the longest duration. | | pan-horizontal | Dragging the timeline will pan only without selected or seeking any media. | + + ### Dimensions Options These options control the aspect-ratio of the entire card to make placement in @@ -961,7 +993,6 @@ media. This only applies to the card in normal render mode -- when in fullscreen, or when in expanded (popup/dialog mode) the aspect ratio is chosen dynamically to maximize the amount of content shown. - All configuration is under: ```yaml @@ -1139,34 +1170,6 @@ automations: | `actions` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `true`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).| | `actions_not` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `false`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).| - - -### Media Layout - -The `live`, `media_viewer` and `image` sections all support `layout` option which is used to control the fit and position of the media _within_ the card dimensions (in order to control the card dimensions themselves see [the dimensions parameter](#dimensions) ). - -As the default card behavior is for the card to always expand to fit the media, these options only make sense if `dimensions.aspect_ratio_mode` is set to `static`. - -All configuration is under: - -```yaml -live: - layout: -image: - layout: -media_viewer: - layout: -``` - -| Option | Default | Overridable | Description | -| - | - | - | - | -| `fit` | `contain` | :white_check_mark: | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the card is fully covered. If `fill`, the media is stretched to fill the card (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. | -| `position` | | :white_check_mark: | A dictionary that contains an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover`. This can be effectively used to "pan" the media around. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the card width (in which case `x` controls the position) or the media height is larger than the card height (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technical details and a visualization. | - -If multiple cameras are configured in the card, use [overrides](#overrides) to configure different values per camera. - -See [media layout examples](#media-layout-examples). - ### Other Options @@ -1782,6 +1785,13 @@ cameras: - binary_sensor.front_door_sensor cast: method: standard + dimensions: + aspect_ratio: 16:9 + layout: + fit: contain + position: + x: 50 + y: 50 - camera_entity: camera.entrance live_provider: webrtc-card engine: auto @@ -2114,11 +2124,6 @@ live: title: mode: popup-bottom-right duration_seconds: 2 - layout: - fit: contain - position: - x: 50 - y: 50 microphone: always_connected: false disconnect_seconds: 90 @@ -2212,11 +2217,6 @@ media_viewer: title: mode: popup-bottom-right duration_seconds: 2 - layout: - fit: contain - position: - x: 50 - y: 50 display: mode: single grid_selected_width_factor: 2 @@ -2281,11 +2281,6 @@ image: mode: url refresh_seconds: 0 zoomable: true - layout: - fit: contain - position: - x: 50 - y: 50 actions: entity: light.office_main_lights tap_action: @@ -3738,20 +3733,16 @@ Change how the media fits and is positioned within the card dimensions.
Expand: Stretching an image -Stretch the stock Frigate card image into a 4:4 square. +Stretch a camera into a 4:4 square. ```yaml type: custom:frigate-card cameras: - camera_entity: camera.landing -view: - default: image -dimensions: - aspect_ratio_mode: static - aspect_ratio: '4:4' -image: - layout: - fit: fill + dimensions: + aspect_ratio: '4:4' + layout: + fit: fill ```
@@ -3764,56 +3755,12 @@ Take the left-hand side (position with x == `0` and use that as the basis of a ` type: custom:frigate-card cameras: - camera_entity: camera.landing -dimensions: - aspect_ratio_mode: static - aspect_ratio: '9:16' -live: - layout: - fit: cover - position: - x: 0 -``` - - -
- Expand: Complex example to vary the media layouts for different cameras - -Configure three cameras (all natively landscape): the first uses automatic expansion of dimensions (the default), the second uses a portrait (`9:16`) card focused on the left side (`x` == `0`) of the media, the third uses a portrait (`9:16`) card focused on the right side (`x` == `100`) of the media. - -```yaml -type: custom:frigate-card -cameras: - - camera_entity: camera.landing - - camera_entity: camera.living_room - - camera_entity: camera.sitting_room -overrides: - - conditions: - camera: - - camera.living_room - - camera.sitting_room - overrides: - dimensions: - aspect_ratio_mode: static - aspect_ratio: '9:16' - live: - layout: - fit: cover - - conditions: - camera: - - camera.living_room - overrides: - live: - layout: - position: - x: 0 - - conditions: - camera: - - camera.sitting_room - overrides: - live: - layout: - position: - x: 100 + dimensions: + aspect_ratio: '9:16' + layout: + fit: cover + position: + x: 0 ```
diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts index d6185d5e..1a5763de 100644 --- a/src/card-controller/style-manager.ts +++ b/src/card-controller/style-manager.ts @@ -1,7 +1,8 @@ +import { StyleInfo } from 'lit/directives/style-map'; import { FrigateCardConfig } from '../config/types'; import { setPerformanceCSSStyles } from '../performance'; +import { aspectRatioToStyle, setOrRemoveAttribute } from '../utils/basic'; import { View } from '../view/view'; -import { setOrRemoveAttribute } from '../utils/basic'; import { CardStyleAPI } from './types'; export class StyleManager { @@ -103,24 +104,23 @@ export class StyleManager { * required). * @returns A padding percentage. */ - public getAspectRatioStyle(): string { + public getAspectRatioStyle(): StyleInfo { const config = this._api.getConfigManager().getConfig(); const view = this._api.getViewManager().getView(); if (config) { if (!this._isAspectRatioEnforced(config, view)) { - return 'auto'; + return aspectRatioToStyle(); } const aspectRatioMode = config.dimensions.aspect_ratio_mode; const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown(); if (lastKnown && aspectRatioMode === 'dynamic') { - return `${lastKnown.width} / ${lastKnown.height}`; + return aspectRatioToStyle({ ratio: [lastKnown.width, lastKnown.height] }); } - - return `${config.dimensions.aspect_ratio[0]} / ${config.dimensions.aspect_ratio[1]}`; + return aspectRatioToStyle({ ratio: config.dimensions.aspect_ratio }); } - return '16 / 9'; + return aspectRatioToStyle({ defaultStatic: true }); } } diff --git a/src/card.ts b/src/card.ts index 9370f2b1..e4973c99 100644 --- a/src/card.ts +++ b/src/card.ts @@ -242,9 +242,6 @@ class FrigateCard extends LitElement { return; } - const cardStyle = { - 'aspect-ratio': this._controller.getStyleManager().getAspectRatioStyle(), - }; const mainClasses = { main: true, 'curve-top': @@ -266,7 +263,7 @@ class FrigateCard extends LitElement { hasHold: frigateCardHasAction(actions.hold_action), hasDoubleClick: frigateCardHasAction(actions.double_tap_action), })} - style="${styleMap(cardStyle)}" + style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}" @frigate-card:message=${(ev: CustomEvent) => this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} @frigate-card:view:change=${(ev: CustomEvent) => diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 4f01c22e..38ab5c0b 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -41,7 +41,7 @@ import { Message, } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; -import { contentsChanged } from '../../utils/basic.js'; +import { aspectRatioToString, contentsChanged } from '../../utils/basic.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js'; import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js'; @@ -900,7 +900,6 @@ export class FrigateCardLiveProvider } } if (changedProps.has('liveConfig')) { - updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout); if (this.liveConfig?.show_image_during_load) { this._importPromises.push(import('./live-image.js')); } @@ -908,6 +907,7 @@ export class FrigateCardLiveProvider this._importPromises.push(import('./../zoomer.js')); } } + if (changedProps.has('cameraConfig')) { const provider = this._getResolvedProvider(); if (provider === 'jsmpeg') { @@ -921,6 +921,14 @@ export class FrigateCardLiveProvider } else if (provider === 'go2rtc') { this._importPromises.push(import('./live-go2rtc.js')); } + + updateElementStyleFromMediaLayoutConfig( + this, + this.cameraConfig?.dimensions?.layout, + ); + this.style.aspectRatio = aspectRatioToString({ + ratio: this.cameraConfig?.dimensions?.aspect_ratio, + }); } } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 1b1791ab..e37a7df0 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -37,6 +37,7 @@ import { import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { mayHaveAudio } from '../utils/audio.js'; import { + aspectRatioToString, contentsChanged, errorToConsole, setOrRemoveAttribute, @@ -396,10 +397,6 @@ export class FrigateCardViewerCarousel extends LitElement { * @param changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('viewerConfig')) { - updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); - } - if (changedProps.has('view')) { const newMedia = this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null; @@ -853,6 +850,18 @@ export class FrigateCardViewerProvider if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { import('./zoomer.js'); } + + if (changedProps.has('media') || changedProps.has('cameraManager')) { + const cameraID = this.media?.getCameraID(); + const cameraConfig = cameraID + ? this.cameraManager?.getStore().getCameraConfig(cameraID) + : null; + updateElementStyleFromMediaLayoutConfig(this, cameraConfig?.dimensions?.layout); + + this.style.aspectRatio = aspectRatioToString({ + ratio: cameraConfig?.dimensions?.aspect_ratio, + }); + } } protected _useZoomIfRequired(template: TemplateResult): TemplateResult { diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index dd13f89f..614ad149 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -6,6 +6,7 @@ import unset from 'lodash-es/unset'; import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './config/types'; import { CONF_CAMERAS, + CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT, CONF_CAMERAS_GLOBAL_IMAGE, CONF_CAMERAS_GLOBAL_JSMPEG, CONF_CAMERAS_GLOBAL_WEBRTC_CARD, @@ -565,4 +566,7 @@ const UPGRADES = [ transform: (val) => (val ? 'default' : null), }, ), + upgradeMoveToWithOverrides('live.layout', CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT), + deleteWithOverrides('media_viewer.layout'), + deleteWithOverrides('image.layout'), ]; diff --git a/src/config/types.ts b/src/config/types.ts index 39d745cc..44bddb1d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -524,19 +524,19 @@ export type FrigateConditional = z.infer; // Cannot use discriminatedUnion since customSchema uses a superRefine, which // causes false rejections. const pictureElementSchema = z.union([ - menuStateIconSchema, + conditionalSchema, + customSchema, + frigateConditionalSchema, + iconSchema, + imageSchema, menuIconSchema, + menuStateIconSchema, menuSubmenuSchema, menuSubmenuSelectSchema, - frigateConditionalSchema, + serviceCallButtonSchema, stateBadgeIconSchema, stateIconSchema, stateLabelSchema, - serviceCallButtonSchema, - iconSchema, - imageSchema, - conditionalSchema, - customSchema, ]); const pictureElementsSchema = pictureElementSchema.array().optional(); export type PictureElements = z.infer; @@ -556,6 +556,21 @@ const mediaLayoutConfigSchema = z.object({ }); export type MediaLayoutConfig = z.infer; +// ************************************************************************* +// Aspect Ratio Configuration +// ************************************************************************* + +const aspectRatioSchema = z + .number() + .array() + .length(2) + .or( + z + .string() + .regex(/^\s*\d+\.?\d*\s*[:/]\s*\d+\.?\d*\s*$/) + .transform((input) => input.split(/[:\/]/).map((d) => Number(d))), + ); + // ************************************************************************* // Image Configuration // Image config base options are used both for the `image` live provider and the @@ -581,7 +596,6 @@ const imageConfigDefault = { const imageConfigSchema = imageBaseConfigSchema .extend({ mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), - layout: mediaLayoutConfigSchema.optional(), zoomable: z.boolean().default(imageConfigDefault.zoomable), }) .merge(actionsSchema) @@ -711,6 +725,7 @@ export type TimelineConfig = z.infer; // ************************************************************************* // Next/Previous Control Configuration // ************************************************************************* + const nextPreviousControlConfigSchema = z.object({ style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']), size: z.number().min(BUTTON_SIZE_MIN), @@ -950,7 +965,6 @@ const liveOverridableConfigSchema = z show_image_during_load: z .boolean() .default(liveConfigDefault.show_image_during_load), - layout: mediaLayoutConfigSchema.optional(), microphone: microphoneConfigSchema.default(liveConfigDefault.microphone), zoomable: z.boolean().default(liveConfigDefault.zoomable), display: viewDisplaySchema, @@ -1138,6 +1152,13 @@ export const cameraConfigSchema = z webrtc_card: webrtcCardConfigSchema.optional(), cast: castSchema.optional(), + + dimensions: z + .object({ + aspect_ratio: aspectRatioSchema.optional(), + layout: mediaLayoutConfigSchema.optional(), + }) + .optional(), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -1412,7 +1433,6 @@ const viewerConfigSchema = z title: titleControlConfigSchema.optional(), }) .default(viewerConfigDefault.controls), - layout: mediaLayoutConfigSchema.optional(), }) .merge(actionsSchema) .default(viewerConfigDefault); @@ -1477,17 +1497,7 @@ export const dimensionsConfigSchema = z aspect_ratio_mode: z .enum(['dynamic', 'static', 'unconstrained']) .default(dimensionsConfigDefault.aspect_ratio_mode), - aspect_ratio: z - .number() - .array() - .length(2) - .or( - z - .string() - .regex(/^\s*\d+\s*[:/]\s*\d+\s*$/) - .transform((input) => input.split(/[:\/]/).map((d) => Number(d))), - ) - .default(dimensionsConfigDefault.aspect_ratio), + aspect_ratio: aspectRatioSchema.default(dimensionsConfigDefault.aspect_ratio), max_height: z.string().default(dimensionsConfigDefault.max_height), min_height: z.string().default(dimensionsConfigDefault.min_height), }) diff --git a/src/const.ts b/src/const.ts index 5addcfae..e7c9e330 100644 --- a/src/const.ts +++ b/src/const.ts @@ -11,6 +11,8 @@ export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH = `${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const; export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH = `${CONF_CAMERAS}.#.cast.dashboard.view_path` as const; +export const CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO = + `${CONF_CAMERAS}.#.dimensions.aspect_ratio` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID = `${CONF_CAMERAS}.#.frigate.client_id` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS = @@ -48,6 +50,12 @@ 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_DIMENSIONS_LAYOUT_FIT = + `${CONF_CAMERAS}.#.dimensions.layout.fit` as const; +export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X = + `${CONF_CAMERAS}.#.dimensions.layout.position.x` as const; +export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y = + `${CONF_CAMERAS}.#.dimensions.layout.position.y` as const; export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION = `${CONF_CAMERAS}.#.triggers.motion` as const; export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY = @@ -66,6 +74,8 @@ export const CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY = `${CONF_CAMERAS_GLOBAL}.triggers.occupancy` as const; export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS = `${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const; +export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT = + `${CONF_CAMERAS_GLOBAL}.dimensions.layout` as const; export const CONF_ELEMENTS = 'elements' as const; @@ -163,11 +173,6 @@ 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; const CONF_LIVE = 'live' as const; export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const; @@ -228,9 +233,6 @@ export const CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS = `${CONF_LIVE}.display.grid_max_columns` as const; export const CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR = `${CONF_LIVE}.display.grid_selected_width_factor` 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_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const; export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const; @@ -247,9 +249,6 @@ export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED = export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const; 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; diff --git a/src/editor.ts b/src/editor.ts index 8c586f97..b9f9493a 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -22,9 +22,9 @@ import { } from './config-mgmt.js'; import { BUTTON_SIZE_MIN, - FRIGATE_MENU_PRIORITY_MAX, FrigateCardConfig, frigateCardConfigDefaults, + FRIGATE_MENU_PRIORITY_MAX, RawFrigateCardConfig, RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, @@ -38,6 +38,10 @@ import { CONF_CAMERAS_ARRAY_CAST_METHOD, CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, + CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO, + CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT, + CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X, + CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y, CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID, CONF_CAMERAS_ARRAY_FRIGATE_LABELS, @@ -67,9 +71,6 @@ import { CONF_DIMENSIONS_ASPECT_RATIO_MODE, CONF_DIMENSIONS_MAX_HEIGHT, CONF_DIMENSIONS_MIN_HEIGHT, - CONF_IMAGE_LAYOUT_FIT, - CONF_IMAGE_LAYOUT_POSITION_X, - CONF_IMAGE_LAYOUT_POSITION_Y, CONF_IMAGE_MODE, CONF_IMAGE_REFRESH_SECONDS, CONF_IMAGE_URL, @@ -108,9 +109,6 @@ import { CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, CONF_LIVE_DISPLAY_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_MICROPHONE_ALWAYS_CONNECTED, @@ -152,16 +150,13 @@ import { CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, CONF_MEDIA_VIEWER_DISPLAY_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_SNAPSHOT_CLICK_PLAYS_CLIP, CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MENU_ALIGNMENT, - CONF_MENU_BUTTON_SIZE, CONF_MENU_BUTTONS, + CONF_MENU_BUTTON_SIZE, CONF_MENU_POSITION, CONF_MENU_STYLE, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, @@ -213,6 +208,8 @@ const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS_CAST = 'cameras.cast'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; +const MENU_CAMERAS_DIMENSIONS = 'cameras.dimensions'; +const MENU_CAMERAS_DIMENSIONS_LAYOUT = 'cameras.dimensions.layout'; const MENU_CAMERAS_ENGINE = 'cameras.engine'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc'; @@ -221,7 +218,6 @@ const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider'; const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card'; -const MENU_IMAGE_LAYOUT = 'image.layout'; const MENU_LIVE_CONTROLS = 'live.controls'; const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous'; const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz'; @@ -229,7 +225,6 @@ const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails'; const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline'; const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title'; const MENU_LIVE_DISPLAY = 'live.display'; -const MENU_LIVE_LAYOUT = 'live.layout'; const MENU_LIVE_MICROPHONE = 'live.microphone'; const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter'; const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails'; @@ -239,7 +234,6 @@ const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails' const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline'; const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title'; const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display'; -const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout'; const MENU_OPTIONS = 'options'; const MENU_PERFORMANCE_FEATURES = 'performance.features'; const MENU_PERFORMANCE_STYLE = 'performance.style'; @@ -576,9 +570,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor 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') }, + { + value: 'contain', + label: localize('config.cameras.dimensions.layout.fits.contain'), + }, + { value: 'cover', label: localize('config.cameras.dimensions.layout.fits.cover') }, + { value: 'fill', label: localize('config.cameras.dimensions.layout.fits.fill') }, ]; protected _miniTimelineModes: EditorSelectOption[] = [ @@ -1167,17 +1164,17 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { name: 'mdi:page-layout-body' }, html` ${this._renderOptionSelector(configPathFit, this._layoutFits, { - label: localize('config.common.layout.fit'), + label: localize('config.cameras.dimensions.layout.fit'), })} ${this._renderNumberInput(configPathPositionX, { min: 0, max: 100, - label: localize('config.common.layout.position.x'), + label: localize('config.cameras.dimensions.layout.position.x'), })} ${this._renderNumberInput(configPathPositionY, { min: 0, max: 100, - label: localize('config.common.layout.position.y'), + label: localize('config.cameras.dimensions.layout.position.y'), })} `, ); @@ -1854,10 +1851,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, )} ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, - cameraIndex, - ), + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, cameraIndex), this._triggersEvents, { multiple: true, @@ -1890,6 +1884,27 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} `, )} + ${this._putInSubmenu( + MENU_CAMERAS_DIMENSIONS, + cameraIndex, + 'config.cameras.dimensions.editor_label', + { name: 'mdi:aspect-ratio' }, + html` + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO, + cameraIndex, + ), + )} + ${this._renderMediaLayout( + MENU_CAMERAS_DIMENSIONS_LAYOUT, + 'config.cameras.dimensions.layout.editor_label', + CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT, + CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X, + CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y, + )} + `, + )} ` : ``} @@ -2237,13 +2252,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} `, )} - ${this._renderMediaLayout( - MENU_LIVE_LAYOUT, - 'config.live.layout', - CONF_LIVE_LAYOUT_FIT, - CONF_LIVE_LAYOUT_POSITION_X, - CONF_LIVE_LAYOUT_POSITION_Y, - )} ${this._putInSubmenu( MENU_LIVE_MICROPHONE, true, @@ -2389,13 +2397,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} `, )} - ${this._renderMediaLayout( - MENU_MEDIA_VIEWER_LAYOUT, - 'config.media_viewer.layout', - CONF_MEDIA_VIEWER_LAYOUT_FIT, - CONF_MEDIA_VIEWER_LAYOUT_POSITION_X, - CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y, - )} ` : ''} ${this._renderOptionSetHeader('image')} @@ -2405,13 +2406,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderStringInput(CONF_IMAGE_URL)} ${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)} ${this._renderSwitch(CONF_IMAGE_ZOOMABLE, this._defaults.image.zoomable)} - ${this._renderMediaLayout( - MENU_IMAGE_LAYOUT, - 'config.image.layout', - CONF_IMAGE_LAYOUT_FIT, - CONF_IMAGE_LAYOUT_POSITION_X, - CONF_IMAGE_LAYOUT_POSITION_Y, - )} ` : ''} ${this._renderOptionSetHeader('timeline')} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index ee928903..6e41fb24 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -15,7 +15,7 @@ "dashboard_path": "Dashboard path", "view_path": "View path" }, - "editor_label": "Cast Options", + "editor_label": "Cast options", "method": "Cast method", "methods": { "dashboard": "Dashboard", @@ -25,7 +25,24 @@ "dependencies": { "all_cameras": "Show events for all cameras with this camera", "cameras": "Show events for specific cameras with this camera", - "editor_label": "Dependency Options" + "editor_label": "Dependency options" + }, + "dimensions": { + "aspect_ratio": "Static aspect ratio", + "editor_label": "Camera dimensions", + "layout": { + "editor_label": "Camera Layout", + "fit": "Layout fit", + "fits": { + "contain": "Media is contained/letterboxed", + "cover": "Media expands proportionally to cover the card", + "fill": "Media is stretched to fill the card" + }, + "position": { + "x": "Horizontal placement percentage", + "y": "Vertical placement percentage" + } + } }, "engines": { "editor_label": "Camera engine options" @@ -83,7 +100,7 @@ }, "title": "Title for this camera (Autodetected from entity)", "triggers": { - "editor_label": "Trigger Options", + "editor_label": "Trigger options", "entities": "Trigger from other entities", "events": { "clips": "Events with new clips", @@ -178,18 +195,6 @@ "grid_selected_width_factor": "Increase selected media width by this factor", "mode": "Mode" }, - "layout": { - "fit": "Layout fit", - "fits": { - "contain": "Media is contained/letterboxed", - "cover": "Media expands proportionally to cover the card", - "fill": "Media is stretched to fill the card" - }, - "position": { - "x": "Horizontal placement percentage", - "y": "Vertical placement percentage" - } - }, "media_action_conditions": { "all": "All opportunities", "hidden": "On browser/tab hiding", @@ -229,7 +234,6 @@ "min_height": "Minimum card height in CSS units (e.g. '100px')" }, "image": { - "layout": "Image Layout", "mode": "Image view mode", "modes": { "camera": "Home Assistant camera snapshot of camera entity", @@ -272,7 +276,6 @@ } }, "draggable": "Live cameras view can be dragged/swiped", - "layout": "Live Layout", "lazy_load": "Live cameras are lazily loaded", "lazy_unload": "Live cameras are lazily unloaded", "microphone": { @@ -296,7 +299,6 @@ "editor_label": "Media Viewer Controls" }, "draggable": "Media Viewer can be dragged/swiped", - "layout": "Media Viewer Layout", "lazy_load": "Media Viewer media is lazily loaded in carousel", "snapshot_click_plays_clip": "Clicking on a snapshot plays a related clip", "transition_effect": "Media Viewer transition effect", @@ -454,8 +456,8 @@ "cameras": "Cameras", "cameras_secondary": "What cameras to render on this card", "delete": "Delete", - "dimensions": "Dimensions", - "dimensions_secondary": "Dimensions & shape options", + "dimensions": "Card dimensions", + "dimensions_secondary": "Card dimensions & shape options", "image": "Image", "image_secondary": "Static image view options", "live": "Live", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 97558030..76f7fa6c 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -27,6 +27,22 @@ "cameras": "Mostra eventi per telecamere specifiche con questa telecamera", "editor_label": "Opzioni di dipendenza" }, + "dimensions": { + "editor_label": "", + "aspect_ratio": "", + "layout": { + "fit": "Adatta al layout", + "fits": { + "contain": "Il supporto è contenuto/in cassetta delle lettere", + "cover": "Il supporto si espande proporzionalmente per coprire la scheda", + "fill": "Il supporto viene allungato per riempire la scheda" + }, + "position": { + "x": "Percentuale di posizionamento orizzontale", + "y": "Percentuale di posizionamento verticale" + } + } + }, "engines": { "editor_label": "Opzioni del motore della fotocamera" }, @@ -178,18 +194,6 @@ "grid_selected_width_factor": "", "mode": "" }, - "layout": { - "fit": "Adatta al layout", - "fits": { - "contain": "Il supporto è contenuto/in cassetta delle lettere", - "cover": "Il supporto si espande proporzionalmente per coprire la scheda", - "fill": "Il supporto viene allungato per riempire la scheda" - }, - "position": { - "x": "Percentuale di posizionamento orizzontale", - "y": "Percentuale di posizionamento verticale" - } - }, "media_action_conditions": { "all": "Tutte le opportunità", "hidden": "Sul browser/nascondere le schede", @@ -229,7 +233,6 @@ "min_height": "" }, "image": { - "layout": "Disposizione dell'immagine", "mode": "Modalità Visualizza immagine", "modes": { "camera": "Istantanea della telecamera di Home Assistant dell'entità telecamera", @@ -272,7 +275,6 @@ } }, "draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere", - "layout": "Disposizione dal vivo", "lazy_load": "Le telecamere dal vivo sono pigramente cariche", "lazy_unload": "Le telecamere dal vivo sono pigramente non caricate", "microphone": { @@ -296,7 +298,6 @@ "editor_label": "Controlli di visualizzatore multimediale" }, "draggable": "Il visualizzatore multimediale può essere trascinato oppure può scorrere", - "layout": "Layout del visualizzatore multimediale", "lazy_load": "Il media Viewer viene caricato pigramente nel carosello", "transition_effect": "Effetto di transizione del visualizzatore multimediale", "transition_effects": { @@ -448,8 +449,8 @@ "cameras": "Telecamere", "cameras_secondary": "Quali telecamere visualizzare su questa card", "delete": "Elimina", - "dimensions": "Dimensioni", - "dimensions_secondary": "Dimensioni e opzioni di forma", + "dimensions": "", + "dimensions_secondary": "", "image": "Immagine", "image_secondary": "Opzioni di visualizzazione dell'immagine statica", "live": "Live", @@ -584,4 +585,4 @@ }, "select_date": "Scegli la data" } -} \ No newline at end of file +} diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 3041dd2f..0243437f 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -27,6 +27,22 @@ "cameras": "Mostrar eventos para câmeras específicas nesta câmera", "editor_label": "Opções de dependência" }, + "dimensions": { + "editor_label": "", + "aspect_ratio": "", + "layout": { + "fit": "Ajuste de layout", + "fits": { + "contain": "A mídia é contida no cartão", + "cover": "A mídia se expande proporcionalmente para cobrir o cartão", + "fill": "A mídia é esticada para preencher o cartão" + }, + "position": { + "x": "Porcentagem do posicionamento horizontal", + "y": "Porcentagem do posicionamento vertical" + } + } + }, "engines": { "editor_label": "Opções do motor da câmera" }, @@ -178,18 +194,6 @@ "grid_selected_width_factor": "", "mode": "" }, - "layout": { - "fit": "Ajuste de layout", - "fits": { - "contain": "A mídia é contida no cartão", - "cover": "A mídia se expande proporcionalmente para cobrir o cartão", - "fill": "A mídia é esticada para preencher o cartão" - }, - "position": { - "x": "Porcentagem do posicionamento horizontal", - "y": "Porcentagem do posicionamento vertical" - } - }, "media_action_conditions": { "all": "Todas as oportunidades", "hidden": "Ao ocultar o navegador/aba", @@ -229,7 +233,6 @@ "min_height": "" }, "image": { - "layout": "Layout da imagem", "mode": "Modo de visualização de imagem", "modes": { "camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera", @@ -272,7 +275,6 @@ } }, "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", - "layout": "Layout dinâmico", "lazy_load": "As câmeras ao vivo são carregadas lentamente", "lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente", "microphone": { @@ -296,7 +298,6 @@ "editor_label": "Controles do visualizador de mídia" }, "draggable": "Visualizador de eventos pode ser arrastado/deslizado", - "layout": "Layout do visualizador de mídia", "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", "snapshot_click_plays_clip": "Clicar em um instantâneo reproduz um clipe relacionado", "transition_effect": "Efeito de transição do Visualizador de eventos", @@ -453,8 +454,8 @@ "cameras": "Câmeras", "cameras_secondary": "Quais câmeras renderizar neste cartão", "delete": "Excluir", - "dimensions": "Dimensões", - "dimensions_secondary": "Dimensões e opções de forma", + "dimensions": "", + "dimensions_secondary": "", "image": "Imagem", "image_secondary": "Opções de visualização de imagem estática", "live": "Ao vivo", @@ -594,4 +595,4 @@ }, "select_date": "Escolha a data" } -} \ No newline at end of file +} diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 78a11864..00d9b704 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -27,6 +27,22 @@ "cameras": "Mostrar eventos para câmeras específicas nesta câmera", "editor_label": "Opções de dependência" }, + "dimensions": { + "editor_label": "", + "aspect_ratio": "", + "layout": { + "fit": "Fit", + "fits": { + "contain": "Conter", + "cover": "Tapar", + "fill": "Preencher" + }, + "position": { + "x": "Percentagem da localização horizontal", + "y": "Percentagem da localização vertical" + } + } + }, "engines": { "editor_label": "Editor de etiquetas" }, @@ -178,18 +194,6 @@ "grid_selected_width_factor": "", "mode": "" }, - "layout": { - "fit": "Fit", - "fits": { - "contain": "Conter", - "cover": "Tapar", - "fill": "Preencher" - }, - "position": { - "x": "Percentagem da localização horizontal", - "y": "Percentagem da localização vertical" - } - }, "media_action_conditions": { "all": "Todas as oportunidades", "hidden": "Ao ocultar o navegador/aba", @@ -222,7 +226,6 @@ } }, "image": { - "layout": "Layout", "mode": "Modo de visualização de imagem", "modes": { "camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera", @@ -265,7 +268,6 @@ } }, "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", - "layout": "layout", "lazy_load": "As câmeras ao vivo são carregadas lentamente", "lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente", "microphone": { @@ -289,7 +291,6 @@ "editor_label": "Controles do visualizador de mídia" }, "draggable": "Visualizador de eventos pode ser arrastado/deslizado", - "layout": "Layout", "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", "transition_effect": "Efeito de transição do Visualizador de eventos", "transition_effects": { @@ -441,8 +442,8 @@ "cameras": "Câmeras", "cameras_secondary": "Câmeras para renderizar neste cartão", "delete": "Excluir", - "dimensions": "Dimensões", - "dimensions_secondary": "Dimensões e opções de forma", + "dimensions": "", + "dimensions_secondary": "", "image": "Imagem", "image_secondary": "Opções de visualização de imagem estática", "live": "Ao vivo", @@ -576,4 +577,4 @@ }, "select_date": "Selecionar a data" } -} \ No newline at end of file +} diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss index 79c460f2..a6b9f29a 100644 --- a/src/scss/live-carousel.scss +++ b/src/scss/live-carousel.scss @@ -3,6 +3,12 @@ --video-max-height: none; } +// When the carousel is not part of a grid ensure its height matches its +// container. +:host(:not([grid-id])) { + height: 100%; +} + // If the carousel has an unselected attribute set on it, do not let the // pointer interact (e.g. hover, scroll) with underlying elements. This is used // when the carousel is part of a media-grid. Without this next/prev controls @@ -13,6 +19,11 @@ } .embla__slide { + // Center the content horizontally (for cases where the configured aspect + // ratio is a mismatch with the card). + display: flex; + justify-content: center; + height: 100%; flex: 0 0 100%; } diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss index 3ac357c1..15c43662 100644 --- a/src/scss/live-provider.scss +++ b/src/scss/live-provider.scss @@ -1,7 +1,7 @@ :host { display: block; height: 100%; - width: 100; + width: 100%; } .hidden { diff --git a/src/utils/basic.ts b/src/utils/basic.ts index a977e9e6..9c074f54 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -2,6 +2,7 @@ import differenceInHours from 'date-fns/differenceInHours'; import differenceInMinutes from 'date-fns/differenceInMinutes'; import differenceInSeconds from 'date-fns/differenceInSeconds'; import format from 'date-fns/format'; +import { StyleInfo } from 'lit/directives/style-map'; import isEqual from 'lodash-es/isEqual'; import mergeWith from 'lodash-es/mergeWith'; import { FrigateCardError } from '../types'; @@ -245,3 +246,25 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => { export const recursivelyMergeObjectsNotArrays = (src1: T, src2: T): T => { return mergeWith({}, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined)); }; + +export const aspectRatioToString = (options?: { + ratio?: number[]; + defaultStatic?: boolean; +}): string => { + if (options?.ratio && options.ratio.length === 2) { + return `${options.ratio[0]} / ${options.ratio[1]}`; + } else if (options?.defaultStatic) { + return '16 / 9'; + } else { + return 'auto'; + } +}; + +export const aspectRatioToStyle = (options?: { + ratio?: number[]; + defaultStatic?: boolean; +}): StyleInfo => { + return { + 'aspect-ratio': aspectRatioToString(options), + }; +}; diff --git a/tests/card-controller/style-manager.test.ts b/tests/card-controller/style-manager.test.ts index 475a3a9f..23a58bae 100644 --- a/tests/card-controller/style-manager.test.ts +++ b/tests/card-controller/style-manager.test.ts @@ -245,7 +245,7 @@ describe('StyleManager', () => { it('without config or view', () => { const api = createCardAPI(); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('16 / 9'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': '16 / 9' }); }); it('should be auto with unconstrained aspect ratio', () => { @@ -260,7 +260,7 @@ describe('StyleManager', () => { }), ); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('auto'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': 'auto' }); }); it('should be auto in fullscreen', () => { @@ -271,7 +271,7 @@ describe('StyleManager', () => { vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(true); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('auto'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': 'auto' }); }); it('should be auto when expanded', () => { @@ -282,7 +282,7 @@ describe('StyleManager', () => { vi.mocked(api.getExpandManager().isExpanded).mockReturnValue(true); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('auto'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': 'auto' }); }); it('should be auto when there is yet to be a view', () => { @@ -291,7 +291,7 @@ describe('StyleManager', () => { vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('auto'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': 'auto' }); }); describe('should be auto when dynamic in certain views', () => { @@ -317,7 +317,7 @@ describe('StyleManager', () => { ); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('auto'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': 'auto' }); }); }); @@ -337,7 +337,7 @@ describe('StyleManager', () => { ); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('16 / 9'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': '16 / 9' }); }, ); }); @@ -362,7 +362,9 @@ describe('StyleManager', () => { }); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('800 / 600'); + expect(manager.getAspectRatioStyle()).toEqual({ + 'aspect-ratio': '800 / 600', + }); }, ); }); @@ -381,7 +383,7 @@ describe('StyleManager', () => { ); const manager = new StyleManager(api); - expect(manager.getAspectRatioStyle()).toBe('4 / 3'); + expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': '4 / 3' }); }); }); }); diff --git a/tests/config-mgmt.test.ts b/tests/config-mgmt.test.ts index 9ca34a98..1b3af522 100644 --- a/tests/config-mgmt.test.ts +++ b/tests/config-mgmt.test.ts @@ -1738,5 +1738,59 @@ describe('should handle version specific upgrades', () => { }); }); }); + + describe('should handle media layout changes', () => { + it('from live.layout to camera_global.dimensions', () => { + const config = { + live: { + layout: { + fit: 'cover', + position: { + x: 42, + y: 43, + }, + }, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: {}, + cameras_global: { + dimensions: { + layout: { + fit: 'cover', + position: { + x: 42, + y: 43, + }, + }, + }, + }, + }); + }); + + describe('from delete old media layouts', () => { + it.each([['media_viewer' as const], ['image' as const]])( + '%s', + (section: string) => { + const config = { + [section]: { + layout: { + fit: 'cover', + position: { + x: 42, + y: 43, + }, + }, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + [section]: {}, + }); + }, + ); + }); + }); }); }); diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 4961b777..4bbadfe6 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -2,8 +2,9 @@ import { afterAll, describe, expect, it, vi } from 'vitest'; import { FrigateCardError } from '../../src/types'; import { allPromises, - arrayMove, arrayify, + arrayMove, + aspectRatioToStyle, contentsChanged, dayToDate, dispatchFrigateCardEvent, @@ -12,16 +13,16 @@ import { formatDateAndTime, getChildrenFromElement, getDurationString, - isHTMLElement, isHoverableDevice, + isHTMLElement, isSuperset, isTruthy, isValidDate, prettifyTitle, recursivelyMergeObjectsNotArrays, runWhenIdleIfSupported, - setOrRemoveAttribute, setify, + setOrRemoveAttribute, sleep, } from '../../src/utils/basic'; import { createSlot, createSlotHost } from '../test-utils'; @@ -338,3 +339,20 @@ describe('recursivelyMergeObjectsNotArrays', () => { }); }); }); + +describe('aspectRatioToStyle', () => { + it('default', () => { + expect(aspectRatioToStyle()).toEqual({ 'aspect-ratio': 'auto' }); + }); + it('default static', () => { + expect(aspectRatioToStyle({ defaultStatic: true })).toEqual({ + 'aspect-ratio': '16 / 9', + }); + }); + it('valid ratio', () => { + expect(aspectRatioToStyle({ ratio: [4, 3] })).toEqual({ 'aspect-ratio': '4 / 3' }); + }); + it('invalid ratio', () => { + expect(aspectRatioToStyle({ ratio: [4] })).toEqual({ 'aspect-ratio': 'auto' }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index ca89f07f..29404bb3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,7 +20,6 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'config-mgmt.ts', 'config/types.ts', 'const.ts', - 'src/view/*.ts', 'types.ts', 'utils/action.ts', 'utils/audio.ts', @@ -40,6 +39,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/substream.ts', 'utils/timer.ts', 'utils/zod.ts', + 'view/*.ts', ]; interface Threshold { @@ -80,13 +80,13 @@ export default defineConfig({ // Expected thresholds for anything that does not have 100% coverage // yet. - statements: 15.54, - branches: 10.38, - functions: 16.18, - lines: 15.84, + statements: 10.67, + branches: 4.9, + functions: 7.81, + lines: 10.92, ...calculateFullCoverageThresholds(), }, }, }, -}); \ No newline at end of file +});