diff --git a/README.md b/README.md
index 570140bd..a3011491 100644
--- a/README.md
+++ b/README.md
@@ -269,6 +269,22 @@ live:
| `style` | `chevrons` | :white_check_mark: | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . |
| `size` | `48px` | :white_check_mark: | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
+
+
+#### Live Controls: Title
+
+All configuration is under:
+
+```yaml
+live:
+ controls:
+ title:
+```
+
+| Option | Default | Overridable | Description |
+| - | - | - | - |
+| `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
+| `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.|
### Event Viewer Options
@@ -318,6 +334,21 @@ event_viewer:
| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).|
| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
+#### Event Viewer Controls: Title
+
+All configuration is under:
+
+```yaml
+event_viewer:
+ controls:
+ title:
+```
+
+| Option | Default | Overridable | Description |
+| - | - | - | - |
+| `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the event viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
+| `duration_seconds` | `2` | :heavy_multiplication_x: | The number of seconds to display the title popup. `0` implies forever.|
+
### Event Gallery Options
The `event_gallery` is used for providing an overview of all `clips` and `snapshots` in a thumbnail gallery.
diff --git a/src/card.ts b/src/card.ts
index 645096c6..732dd0b0 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -143,10 +143,10 @@ export class FrigateCard extends LitElement {
protected _conditionState?: ConditionState;
@query('frigate-card-menu')
- _menu!: FrigateCardMenu;
+ protected _menu!: FrigateCardMenu;
@query('frigate-card-elements')
- _elements?: FrigateCardElements;
+ protected _elements?: FrigateCardElements;
// user interaction timer ("screensaver" functionality, return to default
// view after user interaction).
diff --git a/src/components/live.ts b/src/components/live.ts
index f788fcb2..ad0da375 100644
--- a/src/components/live.ts
+++ b/src/components/live.ts
@@ -21,6 +21,7 @@ import {
} from '../types.js';
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers';
+import JSMpeg from '@cycjimmy/jsmpeg-player';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { Task } from '@lit-labs/task';
import { customElement, property, state } from 'lit/decorators.js';
@@ -47,7 +48,8 @@ import {
} from '../common.js';
import { renderProgressIndicator } from '../components/message.js';
-import JSMpeg from '@cycjimmy/jsmpeg-player';
+import './next-prev-control.js';
+import './title-control.js';
import liveStyle from '../scss/live.scss';
import liveFrigateStyle from '../scss/live-frigate.scss';
@@ -471,7 +473,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
protected render(): TemplateResult | void {
const [slides, cameraToSlide] = this._getSlides();
this._cameraToSlide = cameraToSlide;
- if (!slides || !this.liveConfig) {
+ if (!slides || !this.liveConfig || !this.cameras || !this.view) {
return;
}
@@ -482,6 +484,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
) as LiveConfig;
const [prev, next] = this._getCameraNeighbors();
+ const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera));
+
return html`
+
+
`;
}
}
diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts
index 6814dae1..c71b3f3b 100644
--- a/src/components/media-carousel.ts
+++ b/src/components/media-carousel.ts
@@ -15,6 +15,7 @@ import './next-prev-control.js';
import mediaCarouselStyle from '../scss/media-carousel.scss';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
+import { FrigateCardTitleControl } from './title-control.js';
import { MediaAutoPlayPauseType } from './embla-plugins/media-autoplay.js';
const getEmptyImageSrc = (width: number, height: number) =>
@@ -27,7 +28,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
protected _mediaShowInfo: Record = {};
protected _nextControlRef: Ref = createRef();
protected _previousControlRef: Ref = createRef();
-
+ protected _titleControlRef: Ref = createRef();
+ protected _titleTimerID: number | null = null;
/**
* Play the media on the selected slide. May be overridden to control when
* autoplay should happen.
@@ -36,6 +38,32 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
(this._plugins['MediaAutoPlayPause'] as MediaAutoPlayPauseType | undefined)?.play();
}
+ /**
+ * Show the media title after the media loads.
+ */
+ protected _titleHandler(): void {
+ const show = () => {
+ this._titleTimerID = null;
+ this._titleControlRef.value?.show();
+ };
+
+ if (this._titleTimerID) {
+ window.clearTimeout(this._titleTimerID);
+ }
+ if (this._titleControlRef.value?.isVisible()) {
+ // If it's already visible, update it immediately (but also update it
+ // after the timer expires to ensure it re-positions if necessary, see
+ // comment below).
+ show();
+ }
+
+ // Allow a brief pause after the media loads, but before the title is
+ // displayed. This allows for a pleasant appearance/disappear of the title,
+ // and allows for the browser to finish rendering the carousel (inc.
+ // adaptive height which has `0.5s ease`, see `media-carousel.scss`).
+ this._titleTimerID = window.setTimeout(show, 0.5 * 1000);
+ }
+
/**
* Component connected callback.
*/
@@ -43,6 +71,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
super.connectedCallback();
this.addEventListener('frigate-card:media-show', this._autoplayHandler);
this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
+ this.addEventListener('frigate-card:media-show', this._titleHandler);
}
/**
@@ -52,6 +81,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
super.disconnectedCallback();
this.removeEventListener('frigate-card:media-show', this._autoplayHandler);
this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
+ this.removeEventListener('frigate-card:media-show', this._titleHandler);
}
protected _destroyCarousel(): void {
@@ -93,7 +123,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
* actually the media load/show that will change the dimensions, and that is
* async from carousel actions (e.g. lazy-loaded media).
*/
- protected _adaptiveHeightHandler(): void {
+ protected _adaptiveHeightHandler(): void {
const adaptCarouselHeight = (): void => {
if (!this._carousel) {
return;
diff --git a/src/components/title-control.ts b/src/components/title-control.ts
new file mode 100644
index 00000000..5f0158e5
--- /dev/null
+++ b/src/components/title-control.ts
@@ -0,0 +1,85 @@
+import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
+import { createRef, ref, Ref } from 'lit/directives/ref';
+import { customElement, property } from 'lit/decorators.js';
+
+import { TitleControlConfig } from '../types.js';
+
+import titleStyle from '../scss/title-control.scss';
+
+type PaperToast = HTMLElement & {
+ opened: boolean;
+};
+
+@customElement('frigate-card-title-control')
+export class FrigateCardTitleControl extends LitElement {
+ @property({ attribute: false })
+ public config?: TitleControlConfig;
+
+ @property({ attribute: false })
+ public text?: string;
+
+ @property({ attribute: false })
+ public fitInto?: HTMLElement;
+
+ protected _toastRef: Ref = createRef();
+
+ /**
+ * Master render method.
+ * @returns A rendered template.
+ */
+ protected render(): TemplateResult {
+ if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
+ return html``;
+ }
+
+ const verticalAlign = this.config.mode.match(/-top-/) ? 'top' : 'bottom';
+ const horizontalAlign = this.config.mode.match(/-left$/) ? 'left' : 'right';
+
+ return html`
+ `;
+ }
+
+ /**
+ * Determine if the toast is visible.
+ * @returns `true` if the toast is visible, `false` otherwise.
+ */
+ public isVisible(): boolean {
+ return this._toastRef.value?.opened ?? false;
+ }
+
+ /**
+ * Show the toast.
+ */
+ public hide(): void {
+ if (this._toastRef.value) {
+ // Set it to false first, to ensure the timer resets.
+ this._toastRef.value.opened = false;
+ }
+ }
+
+ /**
+ * Show the toast.
+ */
+ public show(): void {
+ if (this._toastRef.value) {
+ // Set it to false first, to ensure the timer resets.
+ this._toastRef.value.opened = false;
+ this._toastRef.value.opened = true;
+ }
+ }
+
+ /**
+ * Get element styles.
+ */
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(titleStyle);
+ }
+}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index 299ac2f8..8fa228bd 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -41,6 +41,7 @@ import {
import { renderProgressIndicator } from '../components/message.js';
import './next-prev-control.js';
+import './title-control.js';
import viewerStyle from '../scss/viewer.scss';
import viewerCoreStyle from '../scss/viewer-core.scss';
@@ -628,32 +629,41 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
const [prev, next] = [neighbors?.previous, neighbors?.next];
return html`
-
{
- this._nextPreviousHandler('previous');
- }}
- >
-
-
${slides}
+
{
+ this._nextPreviousHandler('previous');
+ }}
+ >
+
+
{
+ this._nextPreviousHandler('next');
+ }}
+ >
-
{
- this._nextPreviousHandler('next');
- }}
- >
-
`;
+ ${this.view?.media
+ ? html`
+ `
+ : ``} `;
}
protected _renderMediaItem(
diff --git a/src/const.ts b/src/const.ts
index 8bf81f9a..e25b6bbe 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -43,6 +43,10 @@ export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE =
`${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const;
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE =
`${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const;
+export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE =
+ `${CONF_EVENT_VIEWER}.controls.title.mode` as const;
+export const CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
+ `${CONF_EVENT_VIEWER}.controls.title.duration_seconds` as const;
export const CONF_LIVE = 'live' as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
@@ -55,6 +59,10 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
`${CONF_LIVE}.controls.thumbnails.mode` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
`${CONF_LIVE}.controls.thumbnails.size` as const;
+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_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;
diff --git a/src/editor.ts b/src/editor.ts
index 0c19c472..328f377c 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -33,6 +33,8 @@ import {
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE,
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
+ CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
+ CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
CONF_EVENT_VIEWER_DRAGGABLE,
CONF_EVENT_VIEWER_LAZY_LOAD,
CONF_IMAGE_REFRESH_SECONDS,
@@ -42,6 +44,8 @@ import {
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
+ CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
+ CONF_LIVE_CONTROLS_TITLE_MODE,
CONF_LIVE_DRAGGABLE,
CONF_LIVE_LAZY_LOAD,
CONF_LIVE_LAZY_UNLOAD,
@@ -233,6 +237,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
['snapshots', localize('config.live.controls.thumbnails.medias.snapshots')],
]);
+ protected _titleModes = new Map([
+ ['', ''],
+ ['none', localize('config.event_viewer.controls.title.modes.none')],
+ ['popup-top-left', localize('config.event_viewer.controls.title.modes.popup-top-left')],
+ ['popup-top-right', localize('config.event_viewer.controls.title.modes.popup-top-right')],
+ [
+ 'popup-bottom-left',
+ localize('config.event_viewer.controls.title.modes.popup-bottom-left'),
+ ],
+ [
+ 'popup-bottom-right',
+ localize('config.event_viewer.controls.title.modes.popup-bottom-right'),
+ ],
+ ]);
+
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
@@ -752,6 +771,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
this._thumbnailMedias,
)}
${this._renderStringInput(CONF_LIVE_CONTROLS_THUMBNAILS_SIZE)}
+ ${this._renderDropdown(
+ CONF_LIVE_CONTROLS_TITLE_MODE,
+ this._titleModes,
+ )}
+ ${this._renderStringInput(
+ CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
+ 'number',
+ )}
`
: ''}
@@ -792,6 +819,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
this._thumbnailModes,
)}
${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE)}
+ ${this._renderDropdown(
+ CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
+ this._titleModes,
+ )}
+ ${this._renderStringInput(
+ CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
+ 'number',
+ )}
`
: ''}
${this._renderOptionSetHeader('image')}
@@ -889,7 +924,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
value = target.checked;
} else if (typeof target.value === 'string') {
value = target.value?.trim();
- if (target['type'] === 'number') {
+ if (target['type'] === 'number' && value != '') {
value = Number(value);
}
} else {
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index 38d1e377..9fd0174e 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -6,7 +6,8 @@
"no_snapshots": "No snapshots",
"no_clips": "No clips",
"no_snapshot": "No recent snapshot",
- "no_clip": "No recent clip"
+ "no_clip": "No recent clip",
+ "live": "Live"
},
"config": {
"cameras": {
@@ -73,6 +74,17 @@
"above": "Thumbnails above the media",
"none": "No thumbnails"
}
+ },
+ "title": {
+ "mode": "Event Viewer media title display mode",
+ "modes": {
+ "none": "No title display",
+ "popup-top-left": "Popup on the top left",
+ "popup-top-right": "Popup on the top right",
+ "popup-bottom-left": "Popup on the bottom left",
+ "popup-bottom-right": "Popup on the bottom right"
+ },
+ "duration_seconds": "Seconds to display popup title in Event Viewer (0=forever)"
}
}
},
@@ -99,6 +111,10 @@
"clips": "Clip thumbnails",
"snapshots": "Snapshot thumbnails"
}
+ },
+ "title": {
+ "mode": "Live media title display mode",
+ "duration_seconds": "Seconds to display popup title in Live view (0=forever)"
}
}
},
diff --git a/src/scss/media-carousel.scss b/src/scss/media-carousel.scss
index 3e5c94f7..aa51db6d 100644
--- a/src/scss/media-carousel.scss
+++ b/src/scss/media-carousel.scss
@@ -2,7 +2,6 @@
--video-max-height: none;
}
-
.embla__container {
// To support adaptive height animations.
transition: max-height 0.5s ease;
diff --git a/src/scss/title-control.scss b/src/scss/title-control.scss
new file mode 100644
index 00000000..20479aec
--- /dev/null
+++ b/src/scss/title-control.scss
@@ -0,0 +1,8 @@
+:host {
+ --paper-toast-background-color: rgba(0,0,0,0.6);
+ --paper-toast-color: white;
+}
+
+paper-toast {
+ max-width: unset;
+}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 48f49bc2..50aa2f15 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -436,6 +436,21 @@ const nextPreviousControlConfigSchema = z.object({
});
export type NextPreviousControlConfig = z.infer;
+/**
+ * Title Control configuration section.
+ */
+const titleControlConfigSchema = z.object({
+ mode: z.enum([
+ 'none',
+ 'popup-top-right',
+ 'popup-top-left',
+ 'popup-bottom-right',
+ 'popup-bottom-left',
+ ]),
+ duration_seconds: z.number().min(0),
+});
+export type TitleControlConfig = z.infer;
+
/**
* Live view configuration section.
*/
@@ -454,6 +469,10 @@ const liveConfigDefault = {
size: '100px',
mode: 'none' as const,
},
+ title: {
+ mode: 'popup-bottom-right' as const,
+ duration_seconds: 2,
+ },
},
};
@@ -483,40 +502,46 @@ const jsmpegConfigSchema = z
.optional();
export type JSMPEGConfig = z.infer;
-const liveNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
- // Live cannot show thumbnails, remove that option.
- style: z
- .enum(['none', 'chevrons', 'icons'])
- .default(liveConfigDefault.controls.next_previous.style),
- size: nextPreviousControlConfigSchema.shape.size.default(
- liveConfigDefault.controls.next_previous.size,
- ),
-});
-
-const liveThumbnailControlConfigSchema = thumbnailsControlSchema.extend({
- mode: thumbnailsControlSchema.shape.mode.default(
- liveConfigDefault.controls.thumbnails.mode,
- ),
- size: thumbnailsControlSchema.shape.size.default(
- liveConfigDefault.controls.thumbnails.size,
- ),
- media: z
- .enum(['clips', 'snapshots'])
- .default(liveConfigDefault.controls.thumbnails.media),
-});
-
const liveOverridableConfigSchema = z
.object({
webrtc: webrtcConfigSchema,
jsmpeg: jsmpegConfigSchema,
controls: z
.object({
- next_previous: liveNextPreviousControlConfigSchema.default(
- liveConfigDefault.controls.next_previous,
- ),
- thumbnails: liveThumbnailControlConfigSchema.default(
- liveConfigDefault.controls.thumbnails,
- ),
+ next_previous: nextPreviousControlConfigSchema
+ .extend({
+ // Live cannot show thumbnails, remove that option.
+ style: z
+ .enum(['none', 'chevrons', 'icons'])
+ .default(liveConfigDefault.controls.next_previous.style),
+ size: nextPreviousControlConfigSchema.shape.size.default(
+ liveConfigDefault.controls.next_previous.size,
+ ),
+ })
+ .default(liveConfigDefault.controls.next_previous),
+ thumbnails: thumbnailsControlSchema
+ .extend({
+ mode: thumbnailsControlSchema.shape.mode.default(
+ liveConfigDefault.controls.thumbnails.mode,
+ ),
+ size: thumbnailsControlSchema.shape.size.default(
+ liveConfigDefault.controls.thumbnails.size,
+ ),
+ media: z
+ .enum(['clips', 'snapshots'])
+ .default(liveConfigDefault.controls.thumbnails.media),
+ })
+ .default(liveConfigDefault.controls.thumbnails),
+ title: titleControlConfigSchema
+ .extend({
+ mode: titleControlConfigSchema.shape.mode.default(
+ liveConfigDefault.controls.title.mode,
+ ),
+ duration_seconds: titleControlConfigSchema.shape.duration_seconds.default(
+ liveConfigDefault.controls.title.duration_seconds,
+ ),
+ })
+ .default(liveConfigDefault.controls.title),
})
.default(liveConfigDefault.controls),
})
@@ -589,6 +614,10 @@ const viewerConfigDefault = {
size: '100px',
mode: 'none' as const,
},
+ title: {
+ mode: 'popup-bottom-right' as const,
+ duration_seconds: 2,
+ },
},
};
const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
@@ -621,6 +650,16 @@ const viewerConfigSchema = z
),
})
.default(viewerConfigDefault.controls.thumbnails),
+ title: titleControlConfigSchema
+ .extend({
+ mode: titleControlConfigSchema.shape.mode.default(
+ viewerConfigDefault.controls.title.mode,
+ ),
+ duration_seconds: titleControlConfigSchema.shape.duration_seconds.default(
+ viewerConfigDefault.controls.title.duration_seconds,
+ ),
+ })
+ .default(viewerConfigDefault.controls.title),
})
.default(viewerConfigDefault.controls),
})