Merge pull request #83 from dermotduffy/arbitrary-aspect-ratios

Support arbitrary camera aspect ratios (dynamic, static or constrained)
This commit is contained in:
Dermot Duffy
2021-09-24 19:28:37 -07:00
committed by GitHub
16 changed files with 457 additions and 79 deletions
+37 -1
View File
@@ -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 |
| ------------- | - | --------------------------------------------- | | ------------- | - | --------------------------------------------- |
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
); );
}); });
} }
+9 -4
View File
@@ -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 {
+35
View File
@@ -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') +
': ' + ': ' +
+9
View File
@@ -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",
+74
View File
@@ -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>
`
: ''}
`;
}
}
});
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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 */
+5
View File
@@ -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
View File
@@ -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:
+4
View File
@@ -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) {