import { format, fromUnixTime } from 'date-fns'; import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { localize } from '../localize/localize.js'; import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailStyle from '../scss/thumbnail.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import { retainEvent } from '../utils/frigate.js'; import { getEventDurationString } from '../utils/frigate.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { View } from '../view.js'; import { MediaSeek } from './viewer.js'; import { TaskStatus } from '@lit-labs/task'; import type { ExtendedHomeAssistant, FrigateBrowseMediaSource, FrigateEvent, FrigateRecording, } from '../types.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @customElement('frigate-card-thumbnail-feature-event') export class FrigateCardThumbnailFeatureEvent extends LitElement { @property({ attribute: false }) public thumbnail?: string; @property({ attribute: false }) public hass?: ExtendedHomeAssistant; protected _embedThumbnailTask = createFetchThumbnailTask( this, () => this.hass, () => this.thumbnail, false, ); // Only load thumbnails on view in case there is a very large number of them. protected _intersectionObserver: IntersectionObserver; constructor() { super(); this._intersectionObserver = new IntersectionObserver( this._intersectionHandler.bind(this), ); } /** * Component connected callback. */ connectedCallback(): void { this._intersectionObserver.observe(this); super.connectedCallback(); } /** * Component disconnected callback. */ disconnectedCallback(): void { super.disconnectedCallback(); this._intersectionObserver.disconnect(); } /** * Called when the live view intersects with the viewport. * @param entries The IntersectionObserverEntry entries (should be only 1). */ protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { if ( this._embedThumbnailTask.status === TaskStatus.INITIAL && entries.some((entry) => entry.isIntersecting) ) { this._embedThumbnailTask.run(); } } protected render(): TemplateResult | void { const imageOff = html` `; return html`${this.thumbnail ? renderTask( this, this._embedThumbnailTask, (embeddedThumbnail: string | null) => embeddedThumbnail ? html`` : html``, () => imageOff, ) : imageOff} `; } static get styles(): CSSResult { return unsafeCSS(thumbnailFeatureEventStyle); } } @customElement('frigate-card-thumbnail-feature-recording') export class FrigateCardThumbnailFeatureRecording extends LitElement { @property({ attribute: false }) public date?: Date; protected render(): TemplateResult | void { if (!this.date) { return; } return html`
${format(this.date, 'HH:mm')}
${format(this.date, 'MMM do')}
`; } static get styles(): CSSResult { return unsafeCSS(thumbnailFeatureRecordingStyle); } } @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) public event?: FrigateEvent; @property({ attribute: false }) public mediaSeek?: MediaSeek; protected render(): TemplateResult | void { if (!this.event) { return; } const score = (this.event.top_score * 100).toFixed(2) + '%'; return html`
${prettifyTitle(this.event.label)}
${localize('event.start')}: ${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')}
${localize('event.duration')}: ${getEventDurationString(this.event)}
${this.mediaSeek ? html`
${localize('event.seek')} ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}
` : html``}
${score}
`; } static get styles(): CSSResult { return unsafeCSS(thumbnailDetailsStyle); } } @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) public recording?: FrigateRecording; @property({ attribute: false }) public mediaSeek?: MediaSeek; protected render(): TemplateResult | void { if (!this.recording) { return; } return html`
${prettifyTitle(this.recording.camera) || ''}
${this.mediaSeek ? html`
${localize('recording.seek')} ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}
` : html``}
${this.recording.events} ${localize('recording.events')}
`; } static get styles(): CSSResult { return unsafeCSS(thumbnailDetailsStyle); } } @customElement('frigate-card-thumbnail') export class FrigateCardThumbnail extends LitElement { @property({ attribute: true, type: Boolean }) public details = false; @property({ attribute: true, type: Boolean }) public show_favorite_control = false; @property({ attribute: true, type: Boolean }) public show_timeline_control = false; // ====================== // Target-based interface // ====================== @property({ attribute: false }) public target?: FrigateBrowseMediaSource | null; @property({ attribute: false }) public childIndex?: number; @property({ attribute: false }) public mediaSeek?: MediaSeek; // =================================================== // Raw interface (can override target-based interface) // =================================================== @property({ attribute: true }) public thumbnail?: string; @property({ attribute: true }) public label?: string; @property({ attribute: false }) public event?: FrigateEvent; // ================================ // Optional parameters for controls // ================================ @property({ attribute: false }) public view?: Readonly; @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @property({ attribute: false }) public clientID?: string; /** * Render the element. * @returns A template to display to the user. */ protected render(): TemplateResult | void { let event: FrigateEvent | null = null; let recording: FrigateRecording | null = null; let thumbnail: string | null = null; let label: string | null = null; // Take the event / thumbnail / label from the data-bound media (if specified). if (this.target && this.target.children && this.childIndex !== undefined) { const media = this.target.children[this.childIndex]; event = media.frigate?.event ?? null; recording = media.frigate?.recording ?? null; thumbnail = media.thumbnail; label = media.title; } // Always give the overrides preference (if specified). if (this.event) { event = this.event; } thumbnail = this.thumbnail ? this.thumbnail : thumbnail; label = this.label ? this.label : label; if (!event && !recording) { return; } const starClasses = { star: true, starred: !!event?.retain_indefinitely, }; return html` ${event ? html`` : html``} ${this.show_favorite_control && event && this.hass && this.clientID ? html` { stopEventFromActivatingCardWideActions(ev); if (event && this.hass && this.clientID) { retainEvent( this.hass, this.clientID, event.id, !event.retain_indefinitely, ) .then(() => { if (event) { event.retain_indefinitely = !event.retain_indefinitely; this.requestUpdate(); } }) .catch((e) => { errorToConsole(e); }); } }} />` : ``} ${this.details && event ? html`` : this.details && recording ? html`` : html``} ${this.show_timeline_control ? html` { stopEventFromActivatingCardWideActions(ev); if (event) { this.view ?.evolve({ view: 'timeline', target: this.target, childIndex: this.childIndex ?? null, }) .removeContext('timeline') .dispatchChangeEvent(this); } else if (recording) { // Specifically reset the media target/childIndex, as we cannot // 'select' an hour in the timeline rather we set the window to // matching values. this.view ?.evolve({ view: 'timeline', target: null, childIndex: null, }) .mergeInContext({ timeline: { window: { start: fromUnixTime(recording.start_time), end: fromUnixTime(recording.end_time), }, }, }) .dispatchChangeEvent(this); } }} >` : ''}`; } /** * Get element styles. */ static get styles(): CSSResult { return unsafeCSS(thumbnailStyle); } } declare global { interface HTMLElementTagNameMap { 'frigate-card-thumbnail': FrigateCardThumbnail; 'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording; 'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent; 'frigate-card-thumbnail-feature-recording': FrigateCardThumbnailFeatureRecording; 'frigate-card-thumbnail-feature-event': FrigateCardThumbnailFeatureEvent; } }