diff --git a/.vscode/i18n-ally-reviews.yml b/.vscode/i18n-ally-reviews.yml index 5b4628b2..3daa9fde 100644 --- a/.vscode/i18n-ally-reviews.yml +++ b/.vscode/i18n-ally-reviews.yml @@ -29,3 +29,99 @@ reviews: Mostrar imagem estática enquanto a transmissão ao vivo está carregando time: '2022-08-07T23:00:35.117Z' + config.common.layout.fit: + locales: + it: + translation_candidate: + source: en + text: Disposizione adatta + time: '2022-08-12T03:00:50.438Z' + pt-BR: + translation_candidate: + source: en + text: Ajuste de layout + time: '2022-08-12T03:00:54.209Z' + config.common.layout.fits.contain: + locales: + it: + translation_candidate: + source: en + text: I media sono contenuti/incartati + time: '2022-08-12T03:01:00.885Z' + pt-BR: + translation_candidate: + source: en + text: A mídia está contida/em letterbox + time: '2022-08-12T03:01:03.607Z' + config.common.layout.fits.cover: + locales: + it: + translation_candidate: + source: en + text: Il supporto si espande proporzionalmente per coprire la scheda + time: '2022-08-12T03:01:09.001Z' + pt-BR: + translation_candidate: + source: en + text: A mídia se expande proporcionalmente para cobrir o cartão + time: '2022-08-12T03:01:11.240Z' + config.common.layout.fits.fill: + locales: + it: + translation_candidate: + source: en + text: Il supporto viene allungato per riempire la scheda + time: '2022-08-12T03:01:14.657Z' + pt-BR: + translation_candidate: + source: en + text: A mídia é esticada para preencher o cartão + time: '2022-08-12T03:01:17.319Z' + config.common.layout.position.x: + locales: + it: + translation_candidate: + source: en + text: Percentuale di posizionamento orizzontale + time: '2022-08-12T03:01:20.714Z' + pt-BR: + translation_candidate: + source: en + text: Porcentagem de posicionamento horizontal + time: '2022-08-12T03:01:22.619Z' + config.common.layout.position.y: + locales: + it: + translation_candidate: + source: en + text: Percentuale di posizionamento verticale + time: '2022-08-12T03:01:26.332Z' + pt-BR: + translation_candidate: + source: en + text: Porcentagem de posicionamento vertical + time: '2022-08-12T03:01:28.398Z' + config.image.layout: + locales: + it: + translation_candidate: + source: en + text: Disposizione dell'immagine + time: '2022-08-12T03:01:33.020Z' + pt-BR: + translation_candidate: + source: en + text: Esquema de imagem + time: '2022-08-12T03:01:34.987Z' + config.media_viewer.layout: + locales: + it: + translation_candidate: + source: en + text: Layout del visualizzatore multimediale + time: '2022-08-12T03:01:45.033Z' + pt-BR: + translation_candidate: + source: en + text: Layout do visualizador de mídia + time: '2022-08-12T03:01:46.528Z' diff --git a/README.md b/README.md index 04dae47d..71a81f71 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,7 @@ See the [fully expanded live configuration example](#config-expanded-live) for h | `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. | | `jsmpeg` | | :white_check_mark: | Configuration for the `frigate-jsmpeg` live provider. See below.| | `webrtc_card` | | :white_check_mark: | Configuration for the `webrtc-card` live provider. See below.| +| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| #### Live Provider: JSMPEG Configuration @@ -437,6 +438,7 @@ 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: Next / Previous @@ -468,8 +470,8 @@ media_viewer: | `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` | 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_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| -| `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| +| `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.| #### Media Viewer Controls: Title @@ -502,8 +504,8 @@ See the [fully expanded event gallery configuration example](#config-expanded-ev | - | - | - | - | | `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_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| -| `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| +| `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.| | `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 @@ -565,6 +567,8 @@ timeline: | `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| + + ### Dimensions Options These options control the aspect-ratio of the entire card to make placement in @@ -583,8 +587,8 @@ See the [fully expanded dimensions configuration example](#config-expanded-dimen | Option | Default | Overridable | Description | | - | - | - | - | -| `aspect_ratio_mode` | `dynamic` | :heavy_multiplication_x: | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| -| `aspect_ratio` | `16:9` | :heavy_multiplication_x: | The aspect ratio to use. Acceptable values: `:` or `/`. See [aspect ratios](#aspect-ratios) below.| +| `aspect_ratio_mode` | `dynamic` | :white_check_mark: | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| +| `aspect_ratio` | `16:9` | :white_check_mark: | The aspect ratio to use. Acceptable values: `:` or `/`. See [aspect ratios](#aspect-ratios) below.| #### `dimensions.aspect_ratio_mode`: @@ -615,6 +619,8 @@ The card aspect ratio can be changed with the `dimensions.aspect_ratio_mode` and If no aspect ratio is specified or available, but one is needed then `16:9` will be used by default. + + ### Overrides Options All configuration is a list under: @@ -643,6 +649,34 @@ item, that has both of the following parameters set: | `conditions` | | :heavy_multiplication_x: | A set of conditions that must evaluate to `true` in order for the overrides to be applied. See [Frigate Card Conditions](#frigate-card-conditions). | | `overrides` | | :heavy_multiplication_x: |Configuration overrides to be applied. Any configuration parameter described in this documentation as 'Overridable' is supported. | + + +### 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). + ### Using AlexxIT's WebRTC Card @@ -1281,6 +1315,11 @@ live: title: mode: popup-bottom-right duration_seconds: 2 + layout: + fit: contain + position: + x: 50 + y: 50 actions: entity: light.office_main_lights tap_action: @@ -1326,6 +1365,11 @@ media_viewer: title: mode: popup-bottom-right duration_seconds: 2 + layout: + fit: contain + position: + x: 50 + y: 50 actions: entity: light.office_main_lights tap_action: @@ -1382,6 +1426,11 @@ Reference: [Image Options](#image-options). image: mode: url refresh_seconds: 0 + layout: + fit: contain + position: + x: 50 + y: 50 actions: entity: light.office_main_lights tap_action: @@ -2598,6 +2647,95 @@ view: ``` + + +### Changing the Media Layout + +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. + +```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 +``` +
+ +
+ Expand: Convert a landscape camera to a portrait live view + +Take the left-hand side (position with x == `0` and use that as the basis of a `9:16` (i.e. portrait) live view. + +```yaml +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 +``` +
+ + ## Card Refreshes diff --git a/src/components/image.ts b/src/components/image.ts index a2aa6770..b1ffb6f8 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -17,7 +17,8 @@ import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; import { CameraConfig, ImageViewConfig } from '../types.js'; import { isHassDifferent } from '../utils/ha'; -import { dispatchMediaShowEvent } from '../utils/media-info.js'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; import { View } from '../view.js'; import { dispatchErrorMessageEvent } from './message.js'; @@ -99,6 +100,7 @@ export class FrigateCardImage extends LitElement { this._getImageSource.bind(this), ); } + updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout); } // If the camera or view changed, immediately discard the old value (view to @@ -228,7 +230,7 @@ export class FrigateCardImage extends LitElement { ${ref(this._refImage)} src=${live(src)} @load=${(ev: Event) => { - dispatchMediaShowEvent(this, ev); + dispatchMediaLoadedEvent(this, ev); }} @error=${() => { if (this.imageConfig?.mode === 'camera') { diff --git a/src/components/live.ts b/src/components/live.ts index 2e7be0ed..bb8fbbb5 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -47,7 +47,7 @@ import { homeAssistantSignPath } from '../utils/ha'; import { getFullDependentBrowseMediaQueryParameters } from '../utils/ha/browse-media.js'; import { dispatchExistingMediaLoadedInfoAsEvent, - dispatchMediaShowEvent, + dispatchMediaLoadedEvent, dispatchMediaUnloadedEvent, } from '../utils/media-info.js'; import { dispatchViewContextChangeEvent, View } from '../view.js'; @@ -67,6 +67,7 @@ import { EmblaCarouselPlugins } from './carousel.js'; import { renderTask } from '../utils/task.js'; import { classMap } from 'lit/directives/class-map.js'; import './image'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; // Number of seconds a signed URL is valid for. const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -751,6 +752,9 @@ export class FrigateCardLiveProvider extends LitElement { dispatchMediaUnloadedEvent(this); } } + if (changedProps.has('liveConfig')) { + updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout); + } } /** @@ -1080,7 +1084,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement { if (onloadeddata) { onloadeddata.call(video, e); } - dispatchMediaShowEvent(this, video); + dispatchMediaLoadedEvent(this, video); }; } }); @@ -1219,7 +1223,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { // ignore any subsequent calls. if (!videoDecoded && this._jsmpegCanvasElement) { videoDecoded = true; - dispatchMediaShowEvent(this, this._jsmpegCanvasElement); + dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement); resolve(player); } }, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 44d54b6c..6518a123 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -54,6 +54,7 @@ import '../patches/ha-hls-player'; import './surround-thumbnails'; import { EmblaCarouselPlugins } from './carousel.js'; import { renderTask } from '../utils/task.js'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @@ -573,6 +574,16 @@ export class FrigateCardViewerCarousel extends LitElement { return true; } + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('viewerConfig')) { + updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); + } + } + /** * Render the element, resolving the media first if necessary. */ @@ -664,6 +675,12 @@ export class FrigateCardViewerCarousel extends LitElement { } } + /** + * Render a single media item in the viewer carousel. + * @param mediaToRender The FrigateBrowseMediaSource to render. + * @param slideIndex The index of the slide to render. + * @returns A rendered template. + */ protected _renderMediaItem( mediaToRender: FrigateBrowseMediaSource, slideIndex: number, diff --git a/src/const.ts b/src/const.ts index 9e404fb3..ca56f40e 100644 --- a/src/const.ts +++ b/src/const.ts @@ -88,6 +88,11 @@ export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE = `${CONF_MEDIA_VIEWER}.controls.title.mode` as const; export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS = `${CONF_MEDIA_VIEWER}.controls.title.duration_seconds` as const; +export const CONF_MEDIA_VIEWER_LAYOUT_FIT = `${CONF_MEDIA_VIEWER}.layout.fit` as const; +export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X = + `${CONF_MEDIA_VIEWER}.layout.position.x` as const; +export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y = + `${CONF_MEDIA_VIEWER}.layout.position.y` as const; export const CONF_LIVE = 'live' as const; export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const; @@ -113,16 +118,23 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const; export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS = `${CONF_LIVE}.controls.title.duration_seconds` as const; +export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const; +export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const; +export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const; export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; export const CONF_LIVE_JSMPEG = `${CONF_LIVE}.jsmpeg` as const; export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const; export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const; export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const; export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const; -export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = `${CONF_LIVE}.show_image_during_load` as const; +export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = + `${CONF_LIVE}.show_image_during_load` as const; export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const; export const CONF_IMAGE = 'image' as const; +export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const; +export const CONF_IMAGE_LAYOUT_POSITION_X = `${CONF_IMAGE}.layout.position.x` as const; +export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as const; export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const; export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const; export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; diff --git a/src/editor.ts b/src/editor.ts index 996028db..b5f92116 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -36,6 +36,9 @@ import { CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, + CONF_IMAGE_LAYOUT_FIT, + CONF_IMAGE_LAYOUT_POSITION_X, + CONF_IMAGE_LAYOUT_POSITION_Y, CONF_IMAGE_MODE, CONF_IMAGE_REFRESH_SECONDS, CONF_IMAGE_URL, @@ -54,6 +57,9 @@ import { CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, CONF_LIVE_CONTROLS_TITLE_MODE, CONF_LIVE_DRAGGABLE, + CONF_LIVE_LAYOUT_FIT, + CONF_LIVE_LAYOUT_POSITION_X, + CONF_LIVE_LAYOUT_POSITION_Y, CONF_LIVE_LAZY_LOAD, CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_PRELOAD, @@ -73,6 +79,9 @@ import { CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, CONF_MEDIA_VIEWER_DRAGGABLE, + CONF_MEDIA_VIEWER_LAYOUT_FIT, + CONF_MEDIA_VIEWER_LAYOUT_POSITION_X, + CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y, CONF_MEDIA_VIEWER_LAZY_LOAD, CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MENU_ALIGNMENT, @@ -125,8 +134,11 @@ const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; const MENU_CAMERAS_WEBRTC = 'cameras.webrtc'; const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls'; +const MENU_IMAGE_LAYOUT = 'image.layout'; const MENU_LIVE_CONTROLS = 'live.controls'; +const MENU_LIVE_LAYOUT = 'live.layout'; const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls'; +const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout'; const MENU_TIMELINE_CONTROLS = 'timeline.controls'; const MENU_OPTIONS = 'options'; const MENU_VIEW_SCAN = 'scan'; @@ -402,6 +414,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'never', label: localize('config.common.media_action_conditions.never') }, ]; + protected _layoutFits: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'contain', label: localize('config.common.layout.fits.contain') }, + { value: 'cover', label: localize('config.common.layout.fits.cover') }, + { value: 'fill', label: localize('config.common.layout.fits.fill') }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -763,6 +782,43 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor `; } + /** + * Render a media layout section. + * @param domain The submenu domain. + * @param labelPath The path to the label. + * @param configPathFit The path to the fit config. + * @param configPathPositionX The path to the position.x config. + * @param configPathPositionY The path to the position.y config. + * @returns A rendered template. + */ + protected _renderMediaLayout( + domain: string, + labelPath: string, + configPathFit: string, + configPathPositionX: string, + configPathPositionY: string, + ): TemplateResult | void { + return this._putInSubmenu( + domain, + true, + labelPath, + { name: 'mdi:page-layout-body' }, + html` + ${this._renderOptionSelector(configPathFit, this._layoutFits)} + ${this._renderNumberInput(configPathPositionX, { + min: 0, + max: 100, + label: localize('config.common.layout.position.x'), + })} + ${this._renderNumberInput(configPathPositionY, { + min: 0, + max: 100, + label: localize('config.common.layout.position.y'), + })} + `, + ); + } + /** * Render a camera section. * @param cameras The full array of cameras. @@ -1258,6 +1314,13 @@ 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, + )} ` : ''} @@ -1327,44 +1390,53 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor true, 'config.media_viewer.controls.options', { name: 'mdi:gamepad' }, - html` ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - this._eventViewerNextPreviousControlStyles, - )} - ${this._renderNumberInput( - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, - { - min: BUTTON_SIZE_MIN, - }, - )} - ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, - this._thumbnailModes, - )} - ${this._renderNumberInput(CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, { - min: THUMBNAIL_WIDTH_MIN, - max: THUMBNAIL_WIDTH_MAX, - })} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, - defaults.media_viewer.controls.thumbnails.show_details, - )} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - defaults.media_viewer.controls.thumbnails.show_favorite_control, - )} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - defaults.media_viewer.controls.thumbnails.show_timeline_control, - )} - ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, - this._titleModes, - )} - ${this._renderNumberInput( - CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, - { min: 0, max: 60 }, - )}`, + html` + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, + this._eventViewerNextPreviousControlStyles, + )} + ${this._renderNumberInput( + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, + { + min: BUTTON_SIZE_MIN, + }, + )} + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, + this._thumbnailModes, + )} + ${this._renderNumberInput(CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, { + min: THUMBNAIL_WIDTH_MIN, + max: THUMBNAIL_WIDTH_MAX, + })} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, + defaults.media_viewer.controls.thumbnails.show_details, + )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + defaults.media_viewer.controls.thumbnails.show_favorite_control, + )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + defaults.media_viewer.controls.thumbnails.show_timeline_control, + )} + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, + this._titleModes, + )} + ${this._renderNumberInput( + CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, + { min: 0, max: 60 }, + )} + `, + )} + ${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, )} ` : ''} @@ -1374,6 +1446,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)} ${this._renderStringInput(CONF_IMAGE_URL)} ${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)} + ${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 829435e7..edd0f093 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -56,6 +56,18 @@ "selected": "On selection", "unselected": "On unselection", "visible": "On browser/tab visibility" + }, + "layout": { + "fit": "Layout fit", + "fits": { + "cover": "Media expands proportionally to cover the card", + "contain": "Media is contained/letterboxed", + "fill": "Media is stretched to fill the card" + }, + "position": { + "x": "Horizontal placement percentage", + "y": "Vertical placement percentage" + } } }, "dimensions": { @@ -79,6 +91,7 @@ } }, "image": { + "layout": "Image Layout", "mode": "Image view mode", "modes": { "camera": "Home Assistant camera snapshot of camera entity", @@ -122,6 +135,7 @@ } }, "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", "preload": "Preload live view in the background", @@ -172,6 +186,7 @@ }, "draggable": "Media Viewer can be dragged/swiped", "lazy_load": "Media Viewer media is lazily loaded in carousel", + "layout": "Media Viewer Layout", "transition_effect": "Media Viewer transition effect", "transition_effects": { "none": "No transition", diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 8be48613..55e186bc 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck +//@ts-nocheck // ==================================================================== // ** Keep modifications to this file to a minimum ** @@ -9,13 +9,14 @@ // available as compilation time. // ==================================================================== -import { css, html, TemplateResult } from 'lit'; +import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { FrigateCardMediaPlayer } from '../types.js'; -import { dispatchMediaShowEvent } from '../utils/media-info.js'; -import "./ha-hls-player"; -import "./ha-web-rtc-player"; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import './ha-hls-player'; +import './ha-web-rtc-player'; +import liveHAComponentsStyle from '../scss/live-ha-components.scss'; customElements.whenDefined('ha-camera-stream').then(() => { // ======================================================================================== @@ -98,8 +99,8 @@ customElements.whenDefined('ha-camera-stream').then(() => { if (this._shouldRenderMJPEG) { return html` { - dispatchMediaShowEvent(this, e); + @load=${(ev: Event) => { + dispatchMediaLoadedEvent(this, ev); }} .src=${typeof this._connected == 'undefined' || this._connected ? computeMJPEGStreamUrl(this.stateObj) @@ -138,6 +139,7 @@ customElements.whenDefined('ha-camera-stream').then(() => { static get styles(): CSSResultGroup { return [ super.styles, + unsafeCSS(liveHAComponentsStyle), css` :host { width: 100%; @@ -146,7 +148,6 @@ customElements.whenDefined('ha-camera-stream').then(() => { img { width: 100%; height: 100%; - object-fit: contain; } `, ]; @@ -155,7 +156,7 @@ customElements.whenDefined('ha-camera-stream').then(() => { }); declare global { - interface HTMLElementTagNameMap { - "frigate-card-ha-camera-stream": FrigateCardHaCameraStream - } + interface HTMLElementTagNameMap { + 'frigate-card-ha-camera-stream': FrigateCardHaCameraStream; + } } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index b5456155..fa910c91 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -9,11 +9,12 @@ // available as compilation time. // ==================================================================== -import { css, html, TemplateResult } from 'lit'; +import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; -import { dispatchMediaShowEvent } from '../utils/media-info.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import liveHAComponentsStyle from '../scss/live-ha-components.scss'; customElements.whenDefined('ha-hls-player').then(() => { @customElement('frigate-card-ha-hls-player') @@ -85,7 +86,7 @@ customElements.whenDefined('ha-hls-player').then(() => { ?playsinline=${this.playsInline} ?controls=${this.controls} @loadeddata=${(e) => { - dispatchMediaShowEvent(this, e); + dispatchMediaLoadedEvent(this, e); }} > `; @@ -94,15 +95,15 @@ customElements.whenDefined('ha-hls-player').then(() => { static get styles(): CSSResultGroup { return [ super.styles, + unsafeCSS(liveHAComponentsStyle), css` :host { width: 100%; height: 100%; } video { - object-fit: contain; - height: 100%; width: 100%; + height: 100%; } `, ]; diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 6a18242b..5c4a1d44 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -9,11 +9,12 @@ // available as compilation time. // ==================================================================== -import { html, TemplateResult } from 'lit'; +import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; -import { dispatchMediaShowEvent } from '../utils/media-info.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import liveHAComponentsStyle from '../scss/live-ha-components.scss'; customElements.whenDefined('ha-web-rtc-player').then(() => { @customElement('frigate-card-ha-web-rtc-player') @@ -86,11 +87,28 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { ?playsinline=${this.playsInline} ?controls=${this.controls} @loadeddata=${(e) => { - dispatchMediaShowEvent(this, e); + dispatchMediaLoadedEvent(this, e); }} > `; } + + static get styles(): CSSResultGroup { + return [ + super.styles, + unsafeCSS(liveHAComponentsStyle), + css` + :host { + width: 100%; + height: 100%; + } + video { + width: 100%; + height: 100%; + } + `, + ]; + } } }); diff --git a/src/scss/const.scss b/src/scss/const.scss index 8c612c78..4d531702 100644 --- a/src/scss/const.scss +++ b/src/scss/const.scss @@ -1,4 +1,6 @@ :host { --frigate-card-thumbnail-size-max: 175px; - --frigate-card-thumbnail-details-width: calc(var(--frigate-card-thumbnail-size) + 200px); + --frigate-card-thumbnail-details-width: calc( + var(--frigate-card-thumbnail-size) + 200px + ); } \ No newline at end of file diff --git a/src/scss/editor.scss b/src/scss/editor.scss index 9144bd9b..0c251615 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -26,6 +26,11 @@ background: var(--secondary-background-color); display: grid; } +// Space submenus from the next top-level options. +.values + .option, +.submenu + .option { + margin-top: 10px; +} div.upgrade { width: auto; border: 1px dotted var(--primary-color); diff --git a/src/scss/image.scss b/src/scss/image.scss index c8d401d5..954b500b 100644 --- a/src/scss/image.scss +++ b/src/scss/image.scss @@ -1,6 +1,9 @@ +@use 'media-layout.scss'; + img { width: 100%; height: 100%; display: block; - object-fit: scale-down; + + @include media-layout.media-layout(); } \ No newline at end of file diff --git a/src/scss/live-ha-components.scss b/src/scss/live-ha-components.scss new file mode 100644 index 00000000..2815cb6a --- /dev/null +++ b/src/scss/live-ha-components.scss @@ -0,0 +1,5 @@ +@use 'media-layout.scss'; + +img, video { + @include media-layout.media-layout(); +} \ No newline at end of file diff --git a/src/scss/live-jsmpeg.scss b/src/scss/live-jsmpeg.scss index 8adaa565..0cb0dfb6 100644 --- a/src/scss/live-jsmpeg.scss +++ b/src/scss/live-jsmpeg.scss @@ -1,3 +1,5 @@ +@use 'media-layout.scss'; + :host { width: 100%; height: 100%; @@ -8,5 +10,6 @@ canvas { display: block; width: 100%; height: 100%; - object-fit: contain; -} \ No newline at end of file + + @include media-layout.media-layout(); +} diff --git a/src/scss/live-webrtc.scss b/src/scss/live-webrtc.scss index 9a0a1548..6482aed1 100644 --- a/src/scss/live-webrtc.scss +++ b/src/scss/live-webrtc.scss @@ -1,3 +1,5 @@ +@use 'media-layout.scss'; + :host { width: 100%; height: 100%; @@ -12,4 +14,8 @@ // To get the WebRTC player to line up correctly with some themes. margin: 0px; box-shadow: none; -} \ No newline at end of file +} + +#webrtc #video { + @include media-layout.media-layout(); +} diff --git a/src/scss/media-layout.scss b/src/scss/media-layout.scss new file mode 100644 index 00000000..cde335ec --- /dev/null +++ b/src/scss/media-layout.scss @@ -0,0 +1,5 @@ +@mixin media-layout { + object-fit: var(--frigate-card-media-layout-fit, contain); + object-position: var(--frigate-card-media-layout-position-x, 50%) + var(--frigate-card-media-layout-position-y, 50%); +} diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss index 5e7f7390..fdf6042c 100644 --- a/src/scss/viewer-carousel.scss +++ b/src/scss/viewer-carousel.scss @@ -1,3 +1,5 @@ +@use 'media-layout.scss'; + .embla__slide { height: 100%; flex: 0 0 100%; @@ -8,7 +10,5 @@ width: 100%; height: 100%; - // Letterbox media. has similar added directly in - // its element. - object-fit: contain; -} \ No newline at end of file + @include media-layout.media-layout(); +} diff --git a/src/types.ts b/src/types.ts index c7c33be7..a7d4046c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -165,9 +165,11 @@ const moreInfoActionSchema = schemaForType< const customActionSchema = schemaForType< CustomActionConfig & ExtendedConfirmationRestrictionConfig >()( - actionBaseSchema.extend({ - action: z.literal('fire-dom-event'), - }).passthrough(), + actionBaseSchema + .extend({ + action: z.literal('fire-dom-event'), + }) + .passthrough(), ); const noActionSchema = schemaForType< NoActionConfig & ExtendedConfirmationRestrictionConfig @@ -519,6 +521,18 @@ export type PictureElement = z.infer; const pictureElementsSchema = pictureElementSchema.array().optional(); export type PictureElements = z.infer; +/** + * Media layout configuration section. + */ +const mediaLayoutConfigSchema = z.object({ + fit: z.enum(['contain', 'cover', 'fill']).optional(), + position: z.object({ + x: z.number().min(0).max(100).optional(), + y: z.number().min(0).max(100).optional(), + }).optional(), +}); +export type MediaLayoutConfig = z.infer; + /** * View configuration section. */ @@ -580,6 +594,7 @@ const imageConfigSchema = z mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), url: z.string().optional(), refresh_seconds: z.number().min(0).default(imageConfigDefault.refresh_seconds), + layout: mediaLayoutConfigSchema.optional(), }) .merge(actionsSchema) .default(imageConfigDefault); @@ -717,12 +732,14 @@ const liveOverridableConfigSchema = z show_details: thumbnailsControlSchema.shape.show_details.default( liveConfigDefault.controls.thumbnails.show_details, ), - show_favorite_control: thumbnailsControlSchema.shape.show_favorite_control.default( - liveConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: thumbnailsControlSchema.shape.show_timeline_control.default( - liveConfigDefault.controls.thumbnails.show_timeline_control, - ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + liveConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + liveConfigDefault.controls.thumbnails.show_timeline_control, + ), media: z .enum(['clips', 'snapshots']) .default(liveConfigDefault.controls.thumbnails.media), @@ -740,7 +757,10 @@ const liveOverridableConfigSchema = z .default(liveConfigDefault.controls.title), }) .default(liveConfigDefault.controls), - show_image_during_load: z.boolean().default(liveConfigDefault.show_image_during_load), + show_image_during_load: z + .boolean() + .default(liveConfigDefault.show_image_during_load), + layout: mediaLayoutConfigSchema.optional(), }) .merge(actionsSchema); @@ -917,12 +937,14 @@ const viewerConfigSchema = z show_details: thumbnailsControlSchema.shape.show_details.default( viewerConfigDefault.controls.thumbnails.show_details, ), - show_favorite_control: thumbnailsControlSchema.shape.show_favorite_control.default( - viewerConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: thumbnailsControlSchema.shape.show_timeline_control.default( - viewerConfigDefault.controls.thumbnails.show_timeline_control, - ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + viewerConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + viewerConfigDefault.controls.thumbnails.show_timeline_control, + ), }) .default(viewerConfigDefault.controls.thumbnails), title: titleControlConfigSchema @@ -937,6 +959,7 @@ const viewerConfigSchema = z .default(viewerConfigDefault.controls.title), }) .default(viewerConfigDefault.controls), + layout: mediaLayoutConfigSchema.optional(), }) .merge(actionsSchema) .default(viewerConfigDefault); @@ -970,12 +993,14 @@ const galleryConfigSchema = z show_details: thumbnailsControlSchema.shape.show_details.default( galleryConfigDefault.controls.thumbnails.show_details, ), - show_favorite_control: thumbnailsControlSchema.shape.show_favorite_control.default( - galleryConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: thumbnailsControlSchema.shape.show_timeline_control.default( - galleryConfigDefault.controls.thumbnails.show_timeline_control, - ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + galleryConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + galleryConfigDefault.controls.thumbnails.show_timeline_control, + ), }) .default(galleryConfigDefault.controls.thumbnails), }) @@ -1062,12 +1087,14 @@ const timelineConfigSchema = z show_details: thumbnailsControlSchema.shape.show_details.default( timelineConfigDefault.controls.thumbnails.show_details, ), - show_favorite_control: thumbnailsControlSchema.shape.show_favorite_control.default( - timelineConfigDefault.controls.thumbnails.show_favorite_control, - ), - show_timeline_control: thumbnailsControlSchema.shape.show_timeline_control.default( - timelineConfigDefault.controls.thumbnails.show_timeline_control, - ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + timelineConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + timelineConfigDefault.controls.thumbnails.show_timeline_control, + ), }) .default(timelineConfigDefault.controls.thumbnails), }) @@ -1086,6 +1113,7 @@ const overrideConfigurationSchema = z.object({ menu: deepRemoveDefaults(menuConfigSchema).optional(), image: deepRemoveDefaults(imageConfigSchema).optional(), view: deepRemoveDefaults(viewConfigSchema).optional(), + dimensions: deepRemoveDefaults(dimensionsConfigSchema).optional(), }); export type OverrideConfigurationKey = keyof z.infer; @@ -1213,13 +1241,13 @@ export const MESSAGE_TYPE_PRIORITIES = { error: 20, connection: 30, diagnostics: 40, -} +}; export type MessageType = 'info' | 'error' | 'connection' | 'diagnostics'; export interface Message { message: string; - type: MessageType, + type: MessageType; icon?: string; context?: unknown; dotdotdot?: boolean; diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index f7fcee69..e642bece 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -43,7 +43,7 @@ export function createMediaLoadedInfo( * @param element The element to send the event. * @param source An event or HTMLElement that should be used as a source. */ -export function dispatchMediaShowEvent( +export function dispatchMediaLoadedEvent( element: HTMLElement, source: Event | HTMLElement, ): void { diff --git a/src/utils/media-layout.ts b/src/utils/media-layout.ts new file mode 100644 index 00000000..bf5ff24f --- /dev/null +++ b/src/utils/media-layout.ts @@ -0,0 +1,27 @@ +import { MediaLayoutConfig } from '../types'; + +/** + * Update element style from a media configuration. + * @param element The element to update the style for. + * @param mediaLayoutConfig The media config object. + */ +export const updateElementStyleFromMediaLayoutConfig = ( + element: HTMLElement, + mediaLayoutConfig?: MediaLayoutConfig, +) => { + if (mediaLayoutConfig?.fit !== undefined) { + element.style.setProperty('--frigate-card-media-layout-fit', mediaLayoutConfig.fit); + } else { + element.style.removeProperty('--frigate-card-media-layout-fit'); + } + for (const dimension of ['x', 'y']) { + if (mediaLayoutConfig?.position?.[dimension] !== undefined) { + element.style.setProperty( + `--frigate-card-media-layout-position-${dimension}`, + `${mediaLayoutConfig.position[dimension]}%`, + ); + } else { + element.style.removeProperty(`--frigate-card-media-layout-position-${dimension}`); + } + } +};