diff --git a/package.json b/package.json index 6d8a11b6..b502b25f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "component-emitter": "^1.3.0", "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", + "date-fns": "^2.28.0", "embla-carousel": "^6.1.1", "home-assistant-js-websocket": "^6.1.1", "keycharm": "^0.4.0", diff --git a/src/common.ts b/src/common.ts index 77eb7e30..0b3016ab 100644 --- a/src/common.ts +++ b/src/common.ts @@ -8,8 +8,13 @@ import { } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; import { ZodSchema, z } from 'zod'; +import { + differenceInSeconds, + differenceInMinutes, + differenceInHours, + fromUnixTime, +} from 'date-fns'; import { isEqual } from 'lodash-es'; - import { localize } from './localize/localize.js'; import { Actions, @@ -20,6 +25,7 @@ import { FrigateCardAction, FrigateCardCustomAction, frigateCardCustomActionSchema, + FrigateEvent, MediaShowInfo, Message, SignedPath, @@ -589,3 +595,31 @@ export const frigateCardHasAction = ( export const stopEventFromActivatingCardWideActions = (ev: Event): void => { ev.stopPropagation(); }; + +/** + * Convenience function to convert a timestamp to hours, minutes and seconds + * string. Heavily inspired by, and returning the same format as, the Frigate + * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 + * @param event The Frigate event. + * @returns A duration string. + */ +export function getEventDurationString(event: FrigateEvent): string { + if (!event.end_time) { + return localize('event.in_progress'); + } + const start = fromUnixTime(event.start_time); + const end = fromUnixTime(event.end_time); + const hours = differenceInHours(end, start); + const minutes = differenceInMinutes(end, start) - hours * 60; + const seconds = differenceInSeconds(end, start) - hours * 60 - minutes * 60; + let duration = ''; + + if (hours) { + duration += `${hours}h `; + } + if (minutes) { + duration += `${minutes}m `; + } + duration += `${seconds}s`; + return duration; +} diff --git a/src/components/drawer.ts b/src/components/drawer.ts index fff6936c..0ae5c1ee 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -67,7 +67,7 @@ export class FrigateCardDrawer extends LitElement { > diff --git a/src/components/live.ts b/src/components/live.ts index d34c594f..1d17320a 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -152,7 +152,7 @@ export class FrigateCardLive extends LitElement { .target=${parent} .view=${this.view} .config=${config.controls.thumbnails} - .highlightSelected=${false} + .highlight_selected=${false} @frigate-card:carousel:tap=${(ev: CustomEvent) => { const mediaType = browseMediaParams.mediaType; if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) { diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 837b10fd..5506f55b 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -3,6 +3,7 @@ import { CSSResultGroup, TemplateResult, html, unsafeCSS, PropertyValues } from import { EmblaOptionsType } from 'embla-carousel'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { FrigateCardCarousel } from './carousel.js'; @@ -12,6 +13,8 @@ import { stopEventFromActivatingCardWideActions, } from '../common.js'; +import "./thumbnail.js"; + import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; export interface ThumbnailCarouselTap { @@ -29,18 +32,10 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { public selected?: number | null; @property({ attribute: false }) - set config(config: ThumbnailsControlConfig | undefined) { - if (config) { - if (config && config.size !== undefined && config.size !== null) { - this.style.setProperty('--frigate-card-carousel-thumbnail-size', config.size); - } - this._config = config; - } - } - protected _config?: ThumbnailsControlConfig; + public config?: ThumbnailsControlConfig; @property({ attribute: false }) - set highlightSelected(value: boolean) { + set highlight_selected(value: boolean) { this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', value ? '0.6' : '1.0', @@ -113,39 +108,33 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { } const mediaToRender = parent.children[childIndex]; - if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) { + if (!BrowseMediaUtil.isTrueMedia(mediaToRender)) { return; } const classes = { - 'embla__slide': true, + embla__slide: true, 'slide-selected': this.selected == childIndex, }; - return html`
{ - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - - ${mediaToRender?.frigate?.event?.retain_indefinitely ? html` - ` : ``} -
`; + return html` + { + if (this._carousel && this._carousel.clickAllowed()) { + dispatchFrigateCardEvent(this, 'carousel:tap', { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `; } /** @@ -154,7 +143,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { */ protected render(): TemplateResult | void { const slides = this._getSlides(); - if (!slides || !this._config || this._config.mode == 'none') { + if (!slides || !this.config || this.config.mode == 'none') { return; } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts new file mode 100644 index 00000000..fa3d5031 --- /dev/null +++ b/src/components/thumbnail.ts @@ -0,0 +1,74 @@ +import { CSSResult, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { format, fromUnixTime } from 'date-fns'; + +import type { FrigateBrowseMediaSource } from '../types.js'; +import { FrigateCardCarousel } from './carousel.js'; +import { getEventDurationString, prettifyFrigateName } from '../common.js'; +import { localize } from '../localize/localize.js'; + +import thumbnailStyle from '../scss/thumbnail.scss'; + +@customElement('frigate-card-thumbnail') +export class FrigateCardThumbnail extends FrigateCardCarousel { + @property({ attribute: false }) + public media?: FrigateBrowseMediaSource; + + @property({ attribute: true, type: Boolean, reflect: true }) + public details = false; + + @property({ attribute: false }) + set thumbnail_size(size: number) { + this.style.setProperty('--frigate-card-thumbnail-size', String(size)); + } + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + if (!this.media || !this.media.thumbnail) { + return; + } + const event = this.media.frigate?.event; + return html` + + ${event?.retain_indefinitely + ? html` ` + : ``} + ${this.details && event + ? html`
+
+
${prettifyFrigateName(event.label)}
+
+ + ${localize('event.start')}: + ${format(fromUnixTime(event.start_time), 'HH:mm:ss')} + +
+
+ + ${localize('event.duration')}: + ${getEventDurationString(event)} + +
+
+
+
${(event.top_score * 100).toFixed(2) + '%'}
+
+
` + : html``} + `; + } + + /** + * Get element styles. + */ + static get styles(): CSSResult { + return unsafeCSS(thumbnailStyle); + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 3305b84d..66982756 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -281,7 +281,7 @@ export class FrigateCardTimeline extends LitElement { ${ref(this._thumbnailsRef)} direction="vertical" .config=${config} - .highlightSelected=${true} + .highlight_selected=${true} @frigate-card:carousel:tap=${(ev: CustomEvent) => { if (ev.detail.target && ev.detail.childIndex) { this.view diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 58613e91..068653ab 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -206,6 +206,11 @@ "overrides": "Overrides are active", "overrides_secondary": "Dynamic configuration overrides detected" }, + "event": { + "start": "Start", + "duration": "Duration", + "in_progress": "In Progress" + }, "error": { "empty_response": "Received empty response from Home Assistant for request", "invalid_response": "Received invalid response from Home Assistant for request", diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index d8d3ca67..f99a6d9d 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,37 +1,12 @@ :host { - --frigate-card-carousel-thumbnail-size: 100px; - --frigate-card-carousel-thumbnail-opacity: 0.6; + --frigate-card-carousel-thumbnail-opacity: 0.8; } .embla__slide { - flex: 0 0 var(--frigate-card-carousel-thumbnail-size); + flex: 0 0 fit-content; opacity: var(--frigate-card-carousel-thumbnail-opacity); - transition: opacity 0.6s ease, transform 0.2s linear; + transition: opacity 0.6s ease; } .embla__slide.slide-selected { opacity: 1.0; -} -.embla__slide:hover { - transform: scale(1.04); -} -.embla__slide img { - border-radius: 5px; - - // Not 'contain' as some thumbnails may vary in aspect-ratio slightly and - // should be clipped to fill the thumbnail div whilst maintaining - // aspect-ratio. - object-fit: cover; - - // Restrict images to a maximum of thumbnail size. - max-width: var(--frigate-card-carousel-thumbnail-size); - max-height: var(--frigate-card-carousel-thumbnail-size); -} -.favorite { - position: absolute; - transform: translate(-50%, -50%); - height: 24px; - width: 24px; - top: 12%; - left: 90%; - color: yellow; } \ No newline at end of file diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss new file mode 100644 index 00000000..67406a63 --- /dev/null +++ b/src/scss/thumbnail.scss @@ -0,0 +1,65 @@ +:host { + display: flex; + flex-direction: row; + + --frigate-card-thumbnail-size: 100px; +} + +:host([details]) { + border: 1px solid var(--primary-color); + border-radius: 5px; + padding: 2px; +} + +img { + border-radius: 5px; + + // Not 'contain' as some thumbnails may vary in aspect-ratio slightly and + // should be clipped to fill the thumbnail div whilst maintaining + // aspect-ratio. + object-fit: cover; + + // Restrict images to a maximum of thumbnail size. + max-width: var(--frigate-card-thumbnail-size); + max-height: var(--frigate-card-thumbnail-size); + + transition: transform 0.2s linear; +} +img:hover { + transform: scale(1.04); +} + +div.details { + display: flex; + + width: 200px; + margin-left: 5px; + padding: 8px; + color: var(--primary-text-color); +} +div.details div { + display: flex; + flex-direction: column; + justify-content: center; +} +div.details div.left { + flex: 1; +} + +div.larger { + font-size: 1.5rem; +} + +span.heading { + font-weight: bold; +} + +.favorite { + position: absolute; + transform: translate(-50%, -50%); + height: 24px; + width: 24px; + top: 12%; + left: 90%; + color: yellow; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 6aeb65b3..a7e2d71d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -932,22 +932,24 @@ interface BrowseMediaSource { children?: BrowseMediaSource[] | null; } +export interface FrigateEvent { + camera: string; + end_time?: number; + false_positive: boolean; + has_clip: boolean; + has_snapshot: boolean; + id: string; + label: string; + start_time: number; + top_score: number; + zones: string[]; + retain_indefinitely: boolean; +} + export interface FrigateBrowseMediaSource extends BrowseMediaSource { children?: FrigateBrowseMediaSource[] | null; frigate?: { - event: { - camera: string; - end_time: number; - false_positive: boolean; - has_clip: boolean; - has_snapshot: boolean; - id: string; - label: string; - start_time: number; - top_score: number; - zones: string[]; - retain_indefinitely: boolean; - }; + event: FrigateEvent, }; }