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'); + }} + > +
+
${slides}
+
+ { + 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), })