Break out thumbnails into their own component.
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user