Break out thumbnails into their own component.

This commit is contained in:
Dermot Duffy
2022-03-20 18:10:48 -07:00
parent 50596cb68a
commit b9a627702f
11 changed files with 227 additions and 82 deletions
+1
View File
@@ -25,6 +25,7 @@
"component-emitter": "^1.3.0", "component-emitter": "^1.3.0",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"custom-card-helpers": "^1.9.0", "custom-card-helpers": "^1.9.0",
"date-fns": "^2.28.0",
"embla-carousel": "^6.1.1", "embla-carousel": "^6.1.1",
"home-assistant-js-websocket": "^6.1.1", "home-assistant-js-websocket": "^6.1.1",
"keycharm": "^0.4.0", "keycharm": "^0.4.0",
+35 -1
View File
@@ -8,8 +8,13 @@ import {
} from 'custom-card-helpers'; } from 'custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map.js'; import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema, z } from 'zod'; import { ZodSchema, z } from 'zod';
import {
differenceInSeconds,
differenceInMinutes,
differenceInHours,
fromUnixTime,
} from 'date-fns';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import { import {
Actions, Actions,
@@ -20,6 +25,7 @@ import {
FrigateCardAction, FrigateCardAction,
FrigateCardCustomAction, FrigateCardCustomAction,
frigateCardCustomActionSchema, frigateCardCustomActionSchema,
FrigateEvent,
MediaShowInfo, MediaShowInfo,
Message, Message,
SignedPath, SignedPath,
@@ -589,3 +595,31 @@ export const frigateCardHasAction = (
export const stopEventFromActivatingCardWideActions = (ev: Event): void => { export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
ev.stopPropagation(); 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;
}
+1 -1
View File
@@ -67,7 +67,7 @@ export class FrigateCardDrawer extends LitElement {
> >
<ha-icon <ha-icon
class="control" class="control"
icon="${this.open ? 'mdi:menu' : 'mdi:menu-open'}" icon="${this.open ? 'mdi:menu-open' : 'mdi:menu'}"
> >
</ha-icon> </ha-icon>
</div> </div>
+1 -1
View File
@@ -152,7 +152,7 @@ export class FrigateCardLive extends LitElement {
.target=${parent} .target=${parent}
.view=${this.view} .view=${this.view}
.config=${config.controls.thumbnails} .config=${config.controls.thumbnails}
.highlightSelected=${false} .highlight_selected=${false}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => { @frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
const mediaType = browseMediaParams.mediaType; const mediaType = browseMediaParams.mediaType;
if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) { if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) {
+14 -25
View File
@@ -3,6 +3,7 @@ import { CSSResultGroup, TemplateResult, html, unsafeCSS, PropertyValues } from
import { EmblaOptionsType } from 'embla-carousel'; import { EmblaOptionsType } from 'embla-carousel';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
import { FrigateCardCarousel } from './carousel.js'; import { FrigateCardCarousel } from './carousel.js';
@@ -12,6 +13,8 @@ import {
stopEventFromActivatingCardWideActions, stopEventFromActivatingCardWideActions,
} from '../common.js'; } from '../common.js';
import "./thumbnail.js";
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
export interface ThumbnailCarouselTap { export interface ThumbnailCarouselTap {
@@ -29,18 +32,10 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
public selected?: number | null; public selected?: number | null;
@property({ attribute: false }) @property({ attribute: false })
set config(config: ThumbnailsControlConfig | undefined) { public config?: ThumbnailsControlConfig;
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;
@property({ attribute: false }) @property({ attribute: false })
set highlightSelected(value: boolean) { set highlight_selected(value: boolean) {
this.style.setProperty( this.style.setProperty(
'--frigate-card-carousel-thumbnail-opacity', '--frigate-card-carousel-thumbnail-opacity',
value ? '0.6' : '1.0', value ? '0.6' : '1.0',
@@ -113,16 +108,20 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
} }
const mediaToRender = parent.children[childIndex]; const mediaToRender = parent.children[childIndex];
if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) { if (!BrowseMediaUtil.isTrueMedia(mediaToRender)) {
return; return;
} }
const classes = { const classes = {
'embla__slide': true, embla__slide: true,
'slide-selected': this.selected == childIndex, 'slide-selected': this.selected == childIndex,
}; };
return html`<div return html`
<frigate-card-thumbnail
.media=${mediaToRender}
details
thumbnail_size=${ifDefined(this.config?.size)}
class="${classMap(classes)}" class="${classMap(classes)}"
@click=${(ev) => { @click=${(ev) => {
if (this._carousel && this._carousel.clickAllowed()) { if (this._carousel && this._carousel.clickAllowed()) {
@@ -135,17 +134,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
> >
<img </frigate-card-thumbnail>`;
aria-label="${mediaToRender.title}"
src="${mediaToRender.thumbnail}"
title="${mediaToRender.title}"
/>
${mediaToRender?.frigate?.event?.retain_indefinitely ? html`
<ha-icon
class="favorite"
icon="mdi:star"
/>` : ``}
</div>`;
} }
/** /**
@@ -154,7 +143,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const slides = this._getSlides(); const slides = this._getSlides();
if (!slides || !this._config || this._config.mode == 'none') { if (!slides || !this.config || this.config.mode == 'none') {
return; return;
} }
+74
View File
@@ -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`
<img
aria-label="${this.media.title}"
src="${this.media.thumbnail}"
title="${this.media.title}"
/>
${event?.retain_indefinitely
? html` <ha-icon class="favorite" icon="mdi:star" />`
: ``}
${this.details && event
? html` <div class="details">
<div class="left">
<div class="larger">${prettifyFrigateName(event.label)}</div>
<div>
<span>
<span class="heading">${localize('event.start')}:</span>
${format(fromUnixTime(event.start_time), 'HH:mm:ss')}
</span>
</div>
<div>
<span>
<span class="heading">${localize('event.duration')}:</span>
${getEventDurationString(event)}
</span>
</div>
</div>
<div class="right">
<div class="larger">${(event.top_score * 100).toFixed(2) + '%'}</div>
</div>
</div>`
: html``}
`;
}
/**
* Get element styles.
*/
static get styles(): CSSResult {
return unsafeCSS(thumbnailStyle);
}
}
+1 -1
View File
@@ -281,7 +281,7 @@ export class FrigateCardTimeline extends LitElement {
${ref(this._thumbnailsRef)} ${ref(this._thumbnailsRef)}
direction="vertical" direction="vertical"
.config=${config} .config=${config}
.highlightSelected=${true} .highlight_selected=${true}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => { @frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
if (ev.detail.target && ev.detail.childIndex) { if (ev.detail.target && ev.detail.childIndex) {
this.view this.view
+5
View File
@@ -206,6 +206,11 @@
"overrides": "Overrides are active", "overrides": "Overrides are active",
"overrides_secondary": "Dynamic configuration overrides detected" "overrides_secondary": "Dynamic configuration overrides detected"
}, },
"event": {
"start": "Start",
"duration": "Duration",
"in_progress": "In Progress"
},
"error": { "error": {
"empty_response": "Received empty response from Home Assistant for request", "empty_response": "Received empty response from Home Assistant for request",
"invalid_response": "Received invalid response from Home Assistant for request", "invalid_response": "Received invalid response from Home Assistant for request",
+3 -28
View File
@@ -1,37 +1,12 @@
:host { :host {
--frigate-card-carousel-thumbnail-size: 100px; --frigate-card-carousel-thumbnail-opacity: 0.8;
--frigate-card-carousel-thumbnail-opacity: 0.6;
} }
.embla__slide { .embla__slide {
flex: 0 0 var(--frigate-card-carousel-thumbnail-size); flex: 0 0 fit-content;
opacity: var(--frigate-card-carousel-thumbnail-opacity); 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 { .embla__slide.slide-selected {
opacity: 1.0; 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;
}
+65
View File
@@ -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;
}
+8 -6
View File
@@ -932,12 +932,9 @@ interface BrowseMediaSource {
children?: BrowseMediaSource[] | null; children?: BrowseMediaSource[] | null;
} }
export interface FrigateBrowseMediaSource extends BrowseMediaSource { export interface FrigateEvent {
children?: FrigateBrowseMediaSource[] | null;
frigate?: {
event: {
camera: string; camera: string;
end_time: number; end_time?: number;
false_positive: boolean; false_positive: boolean;
has_clip: boolean; has_clip: boolean;
has_snapshot: boolean; has_snapshot: boolean;
@@ -947,7 +944,12 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource {
top_score: number; top_score: number;
zones: string[]; zones: string[];
retain_indefinitely: boolean; retain_indefinitely: boolean;
}; }
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
children?: FrigateBrowseMediaSource[] | null;
frigate?: {
event: FrigateEvent,
}; };
} }