Add ability to change media layout.

This commit is contained in:
Dermot Duffy
2022-08-11 21:29:04 -07:00
parent 4c8b60905c
commit a9c600523c
22 changed files with 576 additions and 109 deletions
+96
View File
@@ -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'
+144 -6
View File
@@ -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.|
<a name="dimensions"></a>
### 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: `<W>:<H>` or `<W>/<H>`. 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: `<W>:<H>` or `<W>/<H>`. 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.
<a name="overrides"></a>
### 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. |
<a name="media-layout"></a>
### 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).
<a name="webrtc"></a>
### 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:
```
</details>
<a name="media-layout-examples"></a>
### Changing the Media Layout
Change how the media fits and is positioned within the card dimensions.
<details>
<summary>Expand: Stretching an image</summary>
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
```
</details>
<details>
<summary>Expand: Convert a landscape camera to a portrait live view</summary>
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
```
</details>
<details>
<summary>Expand: Complex example to vary the media layouts for different cameras</summary>
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
```
</details>
<a name="card-updates"></a>
## Card Refreshes
+4 -2
View File
@@ -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') {
+7 -3
View File
@@ -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);
}
},
+17
View File
@@ -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,
+13 -1
View File
@@ -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;
+117 -38
View File
@@ -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
</div>`;
}
/**
* 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,
)}
</div>
`
: ''}
@@ -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,
)}
</div>`
: ''}
@@ -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,
)}
</div>`
: ''}
${this._renderOptionSetHeader('timeline')}
+15
View File
@@ -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",
+12 -11
View File
@@ -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`
<img
@load=${(e) => {
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;
}
}
+6 -5
View File
@@ -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);
}}
></video>
`;
@@ -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%;
}
`,
];
+21 -3
View File
@@ -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);
}}
></video>
`;
}
static get styles(): CSSResultGroup {
return [
super.styles,
unsafeCSS(liveHAComponentsStyle),
css`
:host {
width: 100%;
height: 100%;
}
video {
width: 100%;
height: 100%;
}
`,
];
}
}
});
+3 -1
View File
@@ -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
);
}
+5
View File
@@ -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);
+4 -1
View File
@@ -1,6 +1,9 @@
@use 'media-layout.scss';
img {
width: 100%;
height: 100%;
display: block;
object-fit: scale-down;
@include media-layout.media-layout();
}
+5
View File
@@ -0,0 +1,5 @@
@use 'media-layout.scss';
img, video {
@include media-layout.media-layout();
}
+5 -2
View File
@@ -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;
}
@include media-layout.media-layout();
}
+7 -1
View File
@@ -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;
}
}
#webrtc #video {
@include media-layout.media-layout();
}
+5
View File
@@ -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%);
}
+4 -4
View File
@@ -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. <frigate-card-ha-hls-player> has similar added directly in
// its element.
object-fit: contain;
}
@include media-layout.media-layout();
}
+58 -30
View File
@@ -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<typeof pictureElementSchema>;
const pictureElementsSchema = pictureElementSchema.array().optional();
export type PictureElements = z.infer<typeof pictureElementsSchema>;
/**
* 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<typeof mediaLayoutConfigSchema>;
/**
* 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<typeof overrideConfigurationSchema>;
@@ -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;
+1 -1
View File
@@ -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 {
+27
View File
@@ -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}`);
}
}
};