Merge pull request #83 from dermotduffy/arbitrary-aspect-ratios
Support arbitrary camera aspect ratios (dynamic, static or constrained)
This commit is contained in:
@@ -87,8 +87,44 @@ lovelace:
|
|||||||
| `menu_mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.|
|
| `menu_mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.|
|
||||||
| `menu_buttons.{frigate, live, clips, snapshots, frigate_ui}` | `true` | Whether or not to show these builtin actions in the card menu. |
|
| `menu_buttons.{frigate, live, clips, snapshots, frigate_ui}` | `true` | Whether or not to show these builtin actions in the card menu. |
|
||||||
| `controls.nextprev` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . |
|
| `controls.nextprev` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . |
|
||||||
|
| `dimensions.aspect_ratio_mode` | `dynamic` | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.|
|
||||||
|
| `dimensions.aspect_ratio` | `16:9` | The aspect ratio to use. Acceptable values: `<W>:<H>` or `<W>/<H>`. See [aspect ratios](#aspect-ratios) below.|
|
||||||
|
|
||||||
### Advanced
|
<a name="aspect-ratios"></a>
|
||||||
|
|
||||||
|
#### Aspect Ratio
|
||||||
|
|
||||||
|
The card can show live cameras, stored events (clip or snapshot) and an event gallery (clips or snapshots). Of these [views](#views), the gallery views have no intrinsic aspect-ratio, whereas the other views have the aspect-ratio of the media.
|
||||||
|
|
||||||
|
The card aspect ratio can be changed with the `dimensions.aspect_ratio_mode` and `dimensions.aspect_ratio` appearance options.
|
||||||
|
|
||||||
|
If no aspect ratio is specified or available, but one is needed then `16:9` will be used by default.
|
||||||
|
|
||||||
|
#### `dimensions.aspect_ratio_mode`:
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
| ------------- | --------------------------------------------- |
|
||||||
|
| `dynamic` | The aspect-ratio of the card will match the aspect-ratio of the last loaded media. |
|
||||||
|
| `static` | A fixed aspect-ratio (as defined by `dimensions.aspect_ratio`) will be applied to all views. |
|
||||||
|
| `unconstrained` | No aspect ratio is enforced in any view, the card will expand with the content (may be especially useful for a panel-mode dashboard). |
|
||||||
|
|
||||||
|
#### `dimensions.aspect_ratio`:
|
||||||
|
|
||||||
|
* `16 / 9` or `16:9`: Default widescreen ratio.
|
||||||
|
* `4 / 3` or `4:3`: Default fullscreen ratio.
|
||||||
|
* `<W>/<H>` or `<W>:<H>`: Any arbitrary aspect-ratio.
|
||||||
|
|
||||||
|
#### Example aspect ratio configuration
|
||||||
|
|
||||||
|
Have the card aspect-ratio dynamically follow the last loaded media, but use `4:3` as the default when there is no such media:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dimensions:
|
||||||
|
aspect_ratio_mode: dynamic
|
||||||
|
aspect_ratio: '4:3'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Options
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------- | - | --------------------------------------------- |
|
| ------------- | - | --------------------------------------------- |
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
||||||
"@babel/plugin-proposal-decorators": "^7.15.4",
|
"@babel/plugin-proposal-decorators": "^7.15.4",
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
"@rollup/plugin-multi-entry": "^4.1.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
||||||
"@typescript-eslint/parser": "^4.30.0",
|
"@typescript-eslint/parser": "^4.30.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
|
|||||||
+1
-3
@@ -6,7 +6,6 @@ import { terser } from 'rollup-plugin-terser';
|
|||||||
import serve from 'rollup-plugin-serve';
|
import serve from 'rollup-plugin-serve';
|
||||||
import json from '@rollup/plugin-json';
|
import json from '@rollup/plugin-json';
|
||||||
import styles from 'rollup-plugin-styles';
|
import styles from 'rollup-plugin-styles';
|
||||||
import multi from '@rollup/plugin-multi-entry';
|
|
||||||
|
|
||||||
const dev = process.env.ROLLUP_WATCH;
|
const dev = process.env.ROLLUP_WATCH;
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ const serveopts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const plugins = [
|
const plugins = [
|
||||||
multi(),
|
|
||||||
styles({
|
styles({
|
||||||
modules: false,
|
modules: false,
|
||||||
// Behavior of inject mode, without actually injecting style
|
// Behavior of inject mode, without actually injecting style
|
||||||
@@ -44,7 +42,7 @@ const plugins = [
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
input: ['src/card.ts'],
|
input: 'src/card.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/frigate-hass-card.js',
|
file: 'dist/frigate-hass-card.js',
|
||||||
format: 'es',
|
format: 'es',
|
||||||
|
|||||||
+116
-40
@@ -9,6 +9,7 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, query, state } from 'lit/decorators';
|
import { customElement, property, query, state } from 'lit/decorators';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import {
|
import {
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
LovelaceCardEditor,
|
LovelaceCardEditor,
|
||||||
@@ -17,14 +18,12 @@ import {
|
|||||||
stateIcon,
|
stateIcon,
|
||||||
} from 'custom-card-helpers';
|
} from 'custom-card-helpers';
|
||||||
|
|
||||||
import {
|
import { MenuButton, frigateCardConfigSchema } from './types';
|
||||||
MenuButton,
|
|
||||||
frigateCardConfigSchema,
|
|
||||||
} from './types';
|
|
||||||
import type {
|
import type {
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
|
MediaLoadInfo,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import { CARD_VERSION } from './const';
|
import { CARD_VERSION } from './const';
|
||||||
@@ -39,9 +38,31 @@ import './components/live';
|
|||||||
import './components/menu';
|
import './components/menu';
|
||||||
import './components/message';
|
import './components/message';
|
||||||
import './components/viewer';
|
import './components/viewer';
|
||||||
|
import './patches/ha-camera-stream';
|
||||||
|
import './patches/ha-hls-player';
|
||||||
|
|
||||||
import cardStyle from './scss/card.scss';
|
import cardStyle from './scss/card.scss';
|
||||||
|
|
||||||
|
const MEDIA_HEIGHT_CUTOFF = 50;
|
||||||
|
const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF;
|
||||||
|
|
||||||
|
/** A note on media callbacks:
|
||||||
|
*
|
||||||
|
* We need media elements (e.g. <video>, <img> or <canvas>) to callback when:
|
||||||
|
* - Metadata is loaded / dimensions are known (for aspect-ratio)
|
||||||
|
* - Media is playing / paused (to avoid reloading)
|
||||||
|
*
|
||||||
|
* There are a number of different approaches used to attach event handlers to
|
||||||
|
* get these callbacks (which need to be attached directly to the media
|
||||||
|
* elements, which may be 'buried' down the DOM):
|
||||||
|
* - Extend the `ha-hls-player` and `ha-camera-stream` to specify the required
|
||||||
|
* hooks (as querySelecting the media elements after rendering was a fight
|
||||||
|
* with the Lit rendering engine and was very fragile) .
|
||||||
|
* - For non-Lit elements (e.g. WebRTC) query selecting after rendering.
|
||||||
|
* - Library provided hooks (e.g. JSMPEG)
|
||||||
|
* - Directly specifying hooks (e.g. for snapshot viewing with simple <img> tags)
|
||||||
|
*/
|
||||||
|
|
||||||
/* eslint no-console: 0 */
|
/* eslint no-console: 0 */
|
||||||
console.info(
|
console.info(
|
||||||
`%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `,
|
`%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `,
|
||||||
@@ -102,13 +123,16 @@ export class FrigateCard extends LitElement {
|
|||||||
@query('frigate-card-menu')
|
@query('frigate-card-menu')
|
||||||
_menu!: FrigateCardMenu;
|
_menu!: FrigateCardMenu;
|
||||||
|
|
||||||
// Whether or not there is an active clip being played.
|
// Whether or not media is actively playing (live or clip).
|
||||||
protected _clipPlaying = false;
|
protected _mediaPlaying = false;
|
||||||
|
|
||||||
// A small cache to avoid needing to create a new list of entities every time
|
// A small cache to avoid needing to create a new list of entities every time
|
||||||
// a hass update arrives.
|
// a hass update arrives.
|
||||||
protected _entitiesToMonitor: string[] | null = null;
|
protected _entitiesToMonitor: string[] | null = null;
|
||||||
|
|
||||||
|
// Information about the most recently loaded media item.
|
||||||
|
protected _mediaInfo: MediaLoadInfo | null = null;
|
||||||
|
|
||||||
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
|
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
|
||||||
this._hass = hass;
|
this._hass = hass;
|
||||||
this._updateMenu();
|
this._updateMenu();
|
||||||
@@ -245,8 +269,8 @@ export class FrigateCard extends LitElement {
|
|||||||
// arrive), but also is a jarring experience for the user (e.g. if they
|
// arrive), but also is a jarring experience for the user (e.g. if they
|
||||||
// are browsing the mini-gallery). Do not allow re-rendering from a Home
|
// are browsing the mini-gallery). Do not allow re-rendering from a Home
|
||||||
// Assistant update if there's been recent interaction (e.g. clicks on the
|
// Assistant update if there's been recent interaction (e.g. clicks on the
|
||||||
// card) or if there is a clip active playing.
|
// card) or if there is media active playing.
|
||||||
if (this._interactionTimerID || this._clipPlaying) {
|
if (this._interactionTimerID || this._mediaPlaying) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor);
|
return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor);
|
||||||
@@ -287,35 +311,6 @@ export class FrigateCard extends LitElement {
|
|||||||
return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`;
|
return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public updated(): void {
|
|
||||||
this.updateComplete.then(() => {
|
|
||||||
// DOM elements are not always present until after updateComplete promise
|
|
||||||
// is resolved. Note that children of children (i.e. the underlying video
|
|
||||||
// element) is not always present even when the promise returns, so
|
|
||||||
// capture the event at the upper shadow root instead.
|
|
||||||
const hls_player = this.renderRoot
|
|
||||||
?.querySelector('ha-card')
|
|
||||||
?.querySelector('ha-hls-player');
|
|
||||||
|
|
||||||
if (hls_player) {
|
|
||||||
hls_player.shadowRoot?.addEventListener(
|
|
||||||
'play',
|
|
||||||
() => {
|
|
||||||
this._clipPlaying = true;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
hls_player.shadowRoot?.addEventListener(
|
|
||||||
'pause',
|
|
||||||
() => {
|
|
||||||
this._clipPlaying = true;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record interactions with the card.
|
// Record interactions with the card.
|
||||||
protected _interactionHandler(): void {
|
protected _interactionHandler(): void {
|
||||||
if (!this.config.view_timeout) {
|
if (!this.config.view_timeout) {
|
||||||
@@ -357,6 +352,63 @@ export class FrigateCard extends LitElement {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _playHandler(): void {
|
||||||
|
this._mediaPlaying = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _pauseHandler(): void {
|
||||||
|
this._mediaPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void {
|
||||||
|
const mediaInfo = e.detail;
|
||||||
|
|
||||||
|
// In Safari, with WebRTC, 0x0 is occasionally returned during loading,
|
||||||
|
// so treat anything less than a safety cutoff as bogus.
|
||||||
|
if (mediaInfo.height < MEDIA_HEIGHT_CUTOFF || mediaInfo.width < MEDIA_WIDTH_CUTOFF) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let requestRefresh = false;
|
||||||
|
if (
|
||||||
|
(this.config.dimensions?.aspect_ratio_mode ?? 'dynamic') == 'dynamic' &&
|
||||||
|
(mediaInfo.width != this._mediaInfo?.width ||
|
||||||
|
mediaInfo.height != this._mediaInfo?.height)
|
||||||
|
) {
|
||||||
|
requestRefresh = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mediaInfo = mediaInfo;
|
||||||
|
if (requestRefresh) {
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getAspectRatioPadding(): number | null {
|
||||||
|
const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic';
|
||||||
|
|
||||||
|
// Do not constrain aspect ratio if either it's entire disabled or it's a
|
||||||
|
// media view (i.e. not the gallery) and there's a loaded media item in
|
||||||
|
// dynamic mode (as the aspect_ratio is essentially whatever the media
|
||||||
|
// dimensions are).
|
||||||
|
if (
|
||||||
|
aspect_ratio_mode == 'unconstrained' ||
|
||||||
|
(!this._view.isGalleryView() && aspect_ratio_mode == 'dynamic' && this._mediaInfo)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aspect_ratio_mode == 'dynamic' && this._mediaInfo) {
|
||||||
|
return (this._mediaInfo.height / this._mediaInfo.width) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const default_aspect_ratio = this.config.dimensions?.aspect_ratio;
|
||||||
|
if (default_aspect_ratio) {
|
||||||
|
return (default_aspect_ratio[1] / default_aspect_ratio[0]) * 100;
|
||||||
|
} else {
|
||||||
|
return (9 / 16) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render the call (master render method).
|
// Render the call (master render method).
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (this.config.show_warning) {
|
if (this.config.show_warning) {
|
||||||
@@ -365,10 +417,24 @@ export class FrigateCard extends LitElement {
|
|||||||
if (this.config.show_error) {
|
if (this.config.show_error) {
|
||||||
return this._showError(localize('common.show_error'));
|
return this._showError(localize('common.show_error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const padding = this._getAspectRatioPadding();
|
||||||
|
let containerStyleMap = {};
|
||||||
|
if (padding != null) {
|
||||||
|
containerStyleMap = {
|
||||||
|
'padding-top': `${padding}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentClasses = {
|
||||||
|
'frigate-card-contents': true,
|
||||||
|
absolute: padding != null,
|
||||||
|
};
|
||||||
|
|
||||||
return html` <ha-card @click=${this._interactionHandler}>
|
return html` <ha-card @click=${this._interactionHandler}>
|
||||||
${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
|
${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
|
||||||
<div class="container_16_9 outer">
|
<div class="container outer" style="${styleMap(containerStyleMap)}">
|
||||||
<div class="frigate-card-contents">
|
<div class="${classMap(contentClasses)}">
|
||||||
${this._view.is('clips') || this._view.is('snapshots')
|
${this._view.is('clips') || this._view.is('snapshots')
|
||||||
? html` <frigate-card-gallery
|
? html` <frigate-card-gallery
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
@@ -383,9 +449,13 @@ export class FrigateCard extends LitElement {
|
|||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
.view=${this._view}
|
.view=${this._view}
|
||||||
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
|
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
|
||||||
.nextPreviousControlStyle=${this.config.controls?.nextprev ?? 'thumbnails'}
|
.nextPreviousControlStyle=${this.config.controls?.nextprev ??
|
||||||
|
'thumbnails'}
|
||||||
.autoplayClip=${this.config.autoplay_clip}
|
.autoplayClip=${this.config.autoplay_clip}
|
||||||
@frigate-card:change-view=${this._changeViewHandler}
|
@frigate-card:change-view=${this._changeViewHandler}
|
||||||
|
@frigate-card:media-load=${this._mediaLoadHandler}
|
||||||
|
@frigate-card:pause=${this._pauseHandler}
|
||||||
|
@frigate-card:play=${this._playHandler}
|
||||||
>
|
>
|
||||||
</frigate-card-viewer>`
|
</frigate-card-viewer>`
|
||||||
: ``}
|
: ``}
|
||||||
@@ -393,6 +463,9 @@ export class FrigateCard extends LitElement {
|
|||||||
? html` <frigate-card-live
|
? html` <frigate-card-live
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
.config=${this.config}
|
.config=${this.config}
|
||||||
|
@frigate-card:media-load=${this._mediaLoadHandler}
|
||||||
|
@frigate-card:pause=${this._pauseHandler}
|
||||||
|
@frigate-card:play=${this._playHandler}
|
||||||
>
|
>
|
||||||
</frigate-card-live>`
|
</frigate-card-live>`
|
||||||
: ``}
|
: ``}
|
||||||
@@ -426,6 +499,9 @@ export class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
// Get the Lovelace card size.
|
// Get the Lovelace card size.
|
||||||
public getCardSize(): number {
|
public getCardSize(): number {
|
||||||
|
if (this._mediaInfo) {
|
||||||
|
return this._mediaInfo.height / 50;
|
||||||
|
}
|
||||||
return 6;
|
return 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-10
@@ -6,6 +6,7 @@ import type {
|
|||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
BrowseMediaSource,
|
BrowseMediaSource,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
|
MediaLoadInfo,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { browseMediaSourceSchema } from './types';
|
import { browseMediaSourceSchema } from './types';
|
||||||
|
|
||||||
@@ -96,20 +97,46 @@ export async function browseMediaQuery(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatchPlayEvent(node: HTMLElement): void {
|
export function dispatchEvent<T>(element: HTMLElement, name: string, detail?: T): void {
|
||||||
node.dispatchEvent(
|
element.dispatchEvent(
|
||||||
new CustomEvent<void>('frigate-card:play', {
|
new CustomEvent<T>(`frigate-card:${name}`, {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
|
detail: detail,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatchPauseEvent(node: HTMLElement): void {
|
export function dispatchPlayEvent(element: HTMLElement): void {
|
||||||
node.dispatchEvent(
|
dispatchEvent(element, 'play')
|
||||||
new CustomEvent<void>('frigate-card:pause', {
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dispatchPauseEvent(element: HTMLElement): void {
|
||||||
|
dispatchEvent(element, 'pause')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTMLElement): void {
|
||||||
|
let target: HTMLElement | EventTarget;
|
||||||
|
if (source instanceof Event) {
|
||||||
|
target = source.composedPath()[0];
|
||||||
|
} else {
|
||||||
|
target = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target instanceof HTMLImageElement) {
|
||||||
|
dispatchEvent<MediaLoadInfo>(element, 'media-load', {
|
||||||
|
width: (target as HTMLImageElement).naturalWidth,
|
||||||
|
height: (target as HTMLImageElement).naturalHeight,
|
||||||
|
});
|
||||||
|
} else if (target instanceof HTMLVideoElement) {
|
||||||
|
dispatchEvent<MediaLoadInfo>(element, 'media-load', {
|
||||||
|
width: (target as HTMLVideoElement).videoWidth,
|
||||||
|
height: (target as HTMLVideoElement).videoHeight,
|
||||||
|
});
|
||||||
|
} else if (target instanceof HTMLCanvasElement) {
|
||||||
|
dispatchEvent<MediaLoadInfo>(element, 'media-load', {
|
||||||
|
width: (target as HTMLCanvasElement).width,
|
||||||
|
height: (target as HTMLCanvasElement).height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-10
@@ -7,7 +7,12 @@ import { signedPathSchema } from '../types';
|
|||||||
import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types';
|
import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types';
|
||||||
|
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { homeAssistantWSRequest } from '../common';
|
import {
|
||||||
|
dispatchMediaLoadEvent,
|
||||||
|
dispatchPauseEvent,
|
||||||
|
dispatchPlayEvent,
|
||||||
|
homeAssistantWSRequest,
|
||||||
|
} from '../common';
|
||||||
import {
|
import {
|
||||||
renderMessage,
|
renderMessage,
|
||||||
renderErrorMessage,
|
renderErrorMessage,
|
||||||
@@ -68,13 +73,13 @@ export class FrigateCardViewerFrigate extends LitElement {
|
|||||||
if (!(this.cameraEntity in this.hass.states)) {
|
if (!(this.cameraEntity in this.hass.states)) {
|
||||||
return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off');
|
return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off');
|
||||||
}
|
}
|
||||||
return html` <ha-camera-stream
|
return html` <frigate-card-ha-camera-stream
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.stateObj=${this.hass.states[this.cameraEntity]}
|
.stateObj=${this.hass.states[this.cameraEntity]}
|
||||||
.controls=${true}
|
.controls=${true}
|
||||||
.muted=${true}
|
.muted=${true}
|
||||||
>
|
>
|
||||||
</ha-camera-stream>`;
|
</frigate-card-ha-camera-stream>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
@@ -95,7 +100,6 @@ export class FrigateCardViewerWebRTC extends LitElement {
|
|||||||
protected _webRTCElement: HTMLElement | null = null;
|
protected _webRTCElement: HTMLElement | null = null;
|
||||||
|
|
||||||
protected _createWebRTC(): TemplateResult | void {
|
protected _createWebRTC(): TemplateResult | void {
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const webrtcElement = customElements.get('webrtc-camera') as any;
|
const webrtcElement = customElements.get('webrtc-camera') as any;
|
||||||
if (webrtcElement) {
|
if (webrtcElement) {
|
||||||
@@ -119,6 +123,38 @@ export class FrigateCardViewerWebRTC extends LitElement {
|
|||||||
return html`${this._webRTCElement}`;
|
return html`${this._webRTCElement}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public updated(): void {
|
||||||
|
// Extract the video component after it has been rendered and generate the
|
||||||
|
// media load event.
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
const video = this.renderRoot.querySelector('#video') as HTMLVideoElement;
|
||||||
|
if (video) {
|
||||||
|
const onloadedmetadata = video.onloadedmetadata;
|
||||||
|
const onplay = video.onplay;
|
||||||
|
const onpause = video.onpause;
|
||||||
|
|
||||||
|
video.onloadedmetadata = (e) => {
|
||||||
|
if (onloadedmetadata) {
|
||||||
|
onloadedmetadata.call(video, e);
|
||||||
|
}
|
||||||
|
dispatchMediaLoadEvent(this, video);
|
||||||
|
};
|
||||||
|
video.onplay = (e) => {
|
||||||
|
if (onplay) {
|
||||||
|
onplay.call(video, e);
|
||||||
|
}
|
||||||
|
dispatchPlayEvent(this);
|
||||||
|
};
|
||||||
|
video.onpause = (e) => {
|
||||||
|
if (onpause) {
|
||||||
|
onpause.call(video, e);
|
||||||
|
}
|
||||||
|
dispatchPauseEvent(this);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(liveStyle);
|
return unsafeCSS(liveStyle);
|
||||||
}
|
}
|
||||||
@@ -135,7 +171,7 @@ export class FrigateCardViewerJSMPEG extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected clientId!: string;
|
protected clientId!: string;
|
||||||
|
|
||||||
protected _jsmpegCanvasElement: HTMLElement | null = null;
|
protected _jsmpegCanvasElement: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
protected _jsmpegVideoPlayer: any | null = null;
|
protected _jsmpegVideoPlayer: any | null = null;
|
||||||
@@ -187,10 +223,7 @@ export class FrigateCardViewerJSMPEG extends LitElement {
|
|||||||
return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path');
|
return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the html canvas node only after the JSMPEG video has loaded and
|
let videoDecoded = false;
|
||||||
// is playing, to reduce the amount of time the user is staring at a blank
|
|
||||||
// white canvas (instead they get the progress spinner until this promise
|
|
||||||
// resolves).
|
|
||||||
return new Promise<TemplateResult>((resolve) => {
|
return new Promise<TemplateResult>((resolve) => {
|
||||||
this._jsmpegVideoPlayer = new JSMpeg.VideoElement(
|
this._jsmpegVideoPlayer = new JSMpeg.VideoElement(
|
||||||
this,
|
this,
|
||||||
@@ -198,12 +231,31 @@ export class FrigateCardViewerJSMPEG extends LitElement {
|
|||||||
{
|
{
|
||||||
canvas: this._jsmpegCanvasElement,
|
canvas: this._jsmpegCanvasElement,
|
||||||
hooks: {
|
hooks: {
|
||||||
|
// Don't resolve the promise until it's playing to minimize the
|
||||||
|
// amount of time the canvas is empty (and show the spinner
|
||||||
|
// instead).
|
||||||
play: () => {
|
play: () => {
|
||||||
|
dispatchPlayEvent(this);
|
||||||
resolve(html`${this._jsmpegCanvasElement}`);
|
resolve(html`${this._jsmpegCanvasElement}`);
|
||||||
},
|
},
|
||||||
|
pause: () => {
|
||||||
|
dispatchPauseEvent(this);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocols: [],
|
||||||
|
videoBufferSize: 1024 * 1024 * 4,
|
||||||
|
onVideoDecode: () => {
|
||||||
|
// This is the only callback that is called after the dimensions
|
||||||
|
// are available. It's called on every frame decode, so just
|
||||||
|
// ignore any subsequent calls.
|
||||||
|
if (!videoDecoded && this._jsmpegCanvasElement) {
|
||||||
|
videoDecoded = true;
|
||||||
|
dispatchMediaLoadEvent(this, this._jsmpegCanvasElement);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ protocols: [], videoBufferSize: 1024 * 1024 * 4 },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import {
|
import {
|
||||||
browseMediaQuery,
|
browseMediaQuery,
|
||||||
|
dispatchMediaLoadEvent,
|
||||||
dispatchPauseEvent,
|
dispatchPauseEvent,
|
||||||
dispatchPlayEvent,
|
dispatchPlayEvent,
|
||||||
getFirstTrueMediaChildIndex,
|
getFirstTrueMediaChildIndex,
|
||||||
@@ -199,7 +200,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
|
|
||||||
const neighbors = this._getMediaNeighbors(parent, childIndex);
|
const neighbors = this._getMediaNeighbors(parent, childIndex);
|
||||||
|
|
||||||
return html`
|
return html` <div>
|
||||||
${neighbors?.previousIndex != null
|
${neighbors?.previousIndex != null
|
||||||
? html`<frigate-card-next-previous-control
|
? html`<frigate-card-next-previous-control
|
||||||
.control=${'previous'}
|
.control=${'previous'}
|
||||||
@@ -211,7 +212,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
: ``}
|
: ``}
|
||||||
${this.view.is('clip')
|
${this.view.is('clip')
|
||||||
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
|
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
|
||||||
? html`<ha-hls-player
|
? html`<frigate-card-ha-hls-player
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.url=${resolvedMedia.url}
|
.url=${resolvedMedia.url}
|
||||||
title="${mediaToRender.title}"
|
title="${mediaToRender.title}"
|
||||||
@@ -221,13 +222,14 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
allow-exoplayer
|
allow-exoplayer
|
||||||
?autoplay="${autoplay}"
|
?autoplay="${autoplay}"
|
||||||
>
|
>
|
||||||
</ha-hls-player>`
|
</frigate-card-ha-hls-player>`
|
||||||
: html`<video
|
: html`<video
|
||||||
title="${mediaToRender.title}"
|
title="${mediaToRender.title}"
|
||||||
muted
|
muted
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
?autoplay="${autoplay}"
|
?autoplay="${autoplay}"
|
||||||
|
@loadedmetadata=${(e) => dispatchMediaLoadEvent(this, e)}a
|
||||||
@play=${() => dispatchPlayEvent(this)}
|
@play=${() => dispatchPlayEvent(this)}
|
||||||
@pause=${() => dispatchPauseEvent(this)}
|
@pause=${() => dispatchPauseEvent(this)}
|
||||||
>
|
>
|
||||||
@@ -247,6 +249,9 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@load=${(e) => {
|
||||||
|
dispatchMediaLoadEvent(this, e);
|
||||||
|
}}
|
||||||
/>`}
|
/>`}
|
||||||
${neighbors?.nextIndex != null
|
${neighbors?.nextIndex != null
|
||||||
? html`<frigate-card-next-previous-control
|
? html`<frigate-card-next-previous-control
|
||||||
@@ -257,7 +262,7 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
></frigate-card-next-previous-control>`
|
></frigate-card-next-previous-control>`
|
||||||
: ``}
|
: ``}
|
||||||
`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
none: localize('control.none'),
|
none: localize('control.none'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const aspectRatioModes = {
|
||||||
|
'': '',
|
||||||
|
dynamic: localize('aspect_ratio_mode.dynamic'),
|
||||||
|
static: localize('aspect_ratio_mode.static'),
|
||||||
|
unconstrained: localize('aspect_ratio_mode.unconstrained'),
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="card-config">
|
<div class="card-config">
|
||||||
<div class="option" @click=${this._toggleOption} .option=${'required'}>
|
<div class="option" @click=${this._toggleOption} .option=${'required'}>
|
||||||
@@ -296,6 +303,34 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
</paper-listbox>
|
</paper-listbox>
|
||||||
</paper-dropdown-menu>
|
</paper-dropdown-menu>
|
||||||
<br />
|
<br />
|
||||||
|
<paper-dropdown-menu
|
||||||
|
.label=${localize('dimensions.aspect_ratio_mode')}
|
||||||
|
@value-changed=${this._valueChanged}
|
||||||
|
.configValue=${'dimensions.aspect_ratio_mode'}
|
||||||
|
>
|
||||||
|
<paper-listbox
|
||||||
|
slot="dropdown-content"
|
||||||
|
.selected=${Object.keys(aspectRatioModes).indexOf(
|
||||||
|
this._config?.dimensions?.aspect_ratio_mode || '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
${Object.keys(aspectRatioModes).map((key) => {
|
||||||
|
return html`
|
||||||
|
<paper-item .label="${key}"> ${aspectRatioModes[key]} </paper-item>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
|
</paper-listbox>
|
||||||
|
</paper-dropdown-menu>
|
||||||
|
<br />
|
||||||
|
<paper-input
|
||||||
|
label=${localize('dimensions.aspect_ratio')}
|
||||||
|
prevent-invalid-input
|
||||||
|
.value=${this._config?.dimensions?.aspect_ratio
|
||||||
|
? String(this._config?.dimensions?.aspect_ratio)
|
||||||
|
: ''}
|
||||||
|
.configValue=${'dimensions.aspect_ratio'}
|
||||||
|
@value-changed=${this._valueChanged}
|
||||||
|
></paper-input>
|
||||||
<ha-formfield
|
<ha-formfield
|
||||||
.label=${localize('editor.show_button') +
|
.label=${localize('editor.show_button') +
|
||||||
': ' +
|
': ' +
|
||||||
|
|||||||
@@ -49,6 +49,15 @@
|
|||||||
"chevrons": "Chevrons",
|
"chevrons": "Chevrons",
|
||||||
"none": "None"
|
"none": "None"
|
||||||
},
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"aspect_ratio_mode": "Aspect Ratio Mode",
|
||||||
|
"aspect_ratio": "Default aspect ratio (Optional)"
|
||||||
|
},
|
||||||
|
"aspect_ratio_mode": {
|
||||||
|
"unconstrained": "Unconstrained aspect ratio",
|
||||||
|
"dynamic": "Aspect ratio adjusts to media",
|
||||||
|
"static": "Static aspect ratio"
|
||||||
|
},
|
||||||
"menu_mode": {
|
"menu_mode": {
|
||||||
"none": "No menu",
|
"none": "No menu",
|
||||||
"hidden-top": "Hidden Top",
|
"hidden-top": "Hidden Top",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// ** Keep modifications to this file to a minimum **
|
||||||
|
//
|
||||||
|
// Type checking is disabled since this is a modified copy-and-paste of
|
||||||
|
// underlying render() function, but the rest of the class source it not
|
||||||
|
// available as compilation time.
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
import { TemplateResult, html } from 'lit';
|
||||||
|
import { customElement } from 'lit/decorators';
|
||||||
|
import { dispatchMediaLoadEvent } from '../common';
|
||||||
|
|
||||||
|
customElements.whenDefined('ha-camera-stream').then(() => {
|
||||||
|
// ========================================================================================
|
||||||
|
// From:
|
||||||
|
// - https://github.com/home-assistant/frontend/blob/dev/src/data/camera.ts
|
||||||
|
// - https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_state_name.ts
|
||||||
|
// - https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_object_id.ts
|
||||||
|
// ========================================================================================
|
||||||
|
const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
|
||||||
|
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
|
||||||
|
|
||||||
|
const computeObjectId = (entityId: string): string =>
|
||||||
|
entityId.substr(entityId.indexOf('.') + 1);
|
||||||
|
|
||||||
|
const computeStateName = (stateObj: HassEntity): string =>
|
||||||
|
stateObj.attributes.friendly_name === undefined
|
||||||
|
? computeObjectId(stateObj.entity_id).replace(/_/g, ' ')
|
||||||
|
: stateObj.attributes.friendly_name || '';
|
||||||
|
|
||||||
|
@customElement('frigate-card-ha-camera-stream')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') {
|
||||||
|
// ========================================================================================
|
||||||
|
// Minor modifications from:
|
||||||
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts
|
||||||
|
// ========================================================================================
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
if (!this.stateObj) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this._shouldRenderMJPEG
|
||||||
|
? html`
|
||||||
|
<img
|
||||||
|
@load=${(e) => {
|
||||||
|
this._elementResized();
|
||||||
|
dispatchMediaLoadEvent(this, e);
|
||||||
|
}}
|
||||||
|
.src=${computeMJPEGStreamUrl(this.stateObj)}
|
||||||
|
.alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
|
||||||
|
/>
|
||||||
|
`
|
||||||
|
: this._url
|
||||||
|
? html`
|
||||||
|
<frigate-card-ha-hls-player
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
.allowExoPlayer=${this.allowExoPlayer}
|
||||||
|
.muted=${this.muted}
|
||||||
|
.controls=${this.controls}
|
||||||
|
.hass=${this.hass}
|
||||||
|
.url=${this._url}
|
||||||
|
></frigate-card-ha-hls-player>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// ** Keep modifications to this file to a minimum **
|
||||||
|
//
|
||||||
|
// Type checking is disabled since this is a modified copy-and-paste of
|
||||||
|
// underlying render() function, but the rest of the class source is not
|
||||||
|
// available as compilation time.
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
TemplateResult,
|
||||||
|
html,
|
||||||
|
} from 'lit';
|
||||||
|
import { customElement } from 'lit/decorators';
|
||||||
|
import { dispatchMediaLoadEvent, dispatchPauseEvent, dispatchPlayEvent } from '../common';
|
||||||
|
|
||||||
|
customElements.whenDefined("ha-hls-player").then(() => {
|
||||||
|
@customElement("frigate-card-ha-hls-player")
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
class FrigateCardHaHlsPlayer extends customElements.get("ha-hls-player") {
|
||||||
|
// =====================================================================================
|
||||||
|
// Minor modifications from:
|
||||||
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
||||||
|
// =====================================================================================
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<video
|
||||||
|
?autoplay=${this.autoPlay}
|
||||||
|
.muted=${this.muted}
|
||||||
|
?playsinline=${this.playsInline}
|
||||||
|
?controls=${this.controls}
|
||||||
|
@loadedmetadata=${(e) => dispatchMediaLoadEvent(this, e)}
|
||||||
|
@loadeddata=${this._elementResized}
|
||||||
|
@pause=${() => dispatchPauseEvent(this)}
|
||||||
|
@play=${() => dispatchPlayEvent(this)}
|
||||||
|
></video>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
+10
-9
@@ -1,16 +1,8 @@
|
|||||||
.container_16_9 {
|
.container {
|
||||||
/* 16:9 Aspect Ratio. When Safari supports 'aspect-ratio' this should not be
|
|
||||||
necessary */
|
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-top: 56.25%; // 9 / 16 == 0.5625
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.frigate-card-contents {
|
.frigate-card-contents {
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
right: 0px;
|
|
||||||
bottom: 0px;
|
|
||||||
left: 0px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -24,6 +16,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The 'hover' menu mode is styling applied outside of the menu itself */
|
/* The 'hover' menu mode is styling applied outside of the menu itself */
|
||||||
|
.frigate-card-contents.absolute {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 0px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Support the 'hover' menu mode. */
|
||||||
.hover-menu {
|
.hover-menu {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: all 0.5s ease;
|
transition: all 0.5s ease;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
canvas {
|
canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Don't drop shadow or have radius for nested webrtc card */
|
/* Don't drop shadow or have radius for nested webrtc card */
|
||||||
|
|||||||
@@ -4,4 +4,9 @@ ha-hls-player {
|
|||||||
img,video {
|
img,video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
// Keep the controls positioned relative to the video.
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
+15
-1
@@ -97,6 +97,15 @@ export const frigateCardConfigSchema = z.object({
|
|||||||
nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'),
|
nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
dimensions: z.object({
|
||||||
|
aspect_ratio_mode: z.enum(['dynamic', 'static', 'unconstrained']).default('dynamic'),
|
||||||
|
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([16, 9]),
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
// Stock lovelace card config.
|
// Stock lovelace card config.
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
@@ -126,8 +135,13 @@ export interface BrowseMediaQueryParameters {
|
|||||||
after?: number;
|
after?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaLoadInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media Browser API types.
|
* Home Assistant API types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Recursive type, cannot use type interference:
|
// Recursive type, cannot use type interference:
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export class View {
|
|||||||
return this.view == name;
|
return this.view == name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isGalleryView(): boolean {
|
||||||
|
return this.view == 'clips' || this.view == 'snapshots';
|
||||||
|
}
|
||||||
|
|
||||||
get media(): BrowseMediaSource | undefined {
|
get media(): BrowseMediaSource | undefined {
|
||||||
if (this.target) {
|
if (this.target) {
|
||||||
if (this.target.children && this.childIndex !== undefined) {
|
if (this.target.children && this.childIndex !== undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user