import format from 'date-fns/format'; import fromUnixTime from 'date-fns/fromUnixTime'; 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, getDurationString, prettifyTitle } from '../utils/basic.js'; import { getCameraTitle } from '../utils/camera.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { View } from '../view/view.js'; import { TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; import { CameraManager } from '../camera/manager.js'; import { ViewMediaClassifier } from '../view/media-classifier.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``, { inProgressFunc: () => 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; @property({ attribute: false }) public cameraTitle?: string; protected render(): TemplateResult | void { if (!this.date) { return; } return html`
${format(this.date, 'HH:mm')}
${format(this.date, 'MMM do')}
${this.cameraTitle ? html`
${this.cameraTitle}
` : html``} `; } static get styles(): CSSResult { return unsafeCSS(thumbnailFeatureRecordingStyle); } } @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) public media?: EventViewMedia; @property({ attribute: false }) public seek?: Date; protected render(): TemplateResult | void { if (!this.media) { return; } const score = this.media.getScore(); const startTime = this.media.getStartTime(); const endTime = this.media.getEndTime(); const what = this.media.getWhat(); return html`
${what ? html`
${prettifyTitle(what.join(', '))}
` : ``} ${startTime ? html`
${localize('event.start')}: ${format(startTime, 'HH:mm:ss')}
${localize('event.duration')}: ${endTime ? getDurationString(startTime, endTime) : localize('event.in_progress')}
` : ``} ${this.seek ? html`
${localize('event.seek')} ${format(this.seek, 'HH:mm:ss')}
` : html``}
${score ? html`
${(score * 100).toFixed(2) + '%'}
` : ``}`; } static get styles(): CSSResult { return unsafeCSS(thumbnailDetailsStyle); } } @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) public media?: RecordingViewMedia; @property({ attribute: false }) public seek?: Date; @property({ attribute: false }) public cameraTitle?: string; protected render(): TemplateResult | void { if (!this.media) { return; } const eventCount = this.media.getEventCount(); return html`
${this.cameraTitle ?? ''}
${this.seek ? html`
${localize('recording.seek')} ${format(this.seek, 'HH:mm:ss')}
` : html``}
${eventCount !== null ? html`
${eventCount} ${localize('recording.events')}
` : ``}`; } static get styles(): CSSResult { return unsafeCSS(thumbnailDetailsStyle); } } @customElement('frigate-card-thumbnail') export class FrigateCardThumbnail extends LitElement { // HomeAssistant object may be required for thumbnail signing (for Frigate // events). @property({ attribute: false }) public hass?: ExtendedHomeAssistant; // CameraManager used for marking media as favorite. @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: true }) public media?: ViewMedia; @property({ attribute: false }) public cameraConfig?: CameraConfig; @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; @property({ attribute: false }) public seek?: Date; @property({ attribute: false }) public view?: Readonly; /** * Render the element. * @returns A template to display to the user. */ protected render(): TemplateResult | void { if (!this.media || !this.cameraConfig) { return; } const thumbnail = this.media.getThumbnail(); const title = this.media.getTitle() ?? ''; const starClasses = { star: true, starred: !!this.media?.isFavorite(), }; const shouldShowTimelineControl = this.show_timeline_control && this.view && (!ViewMediaClassifier.isRecording(this.media) || // Only show timeline control if the recording has a start & end time. (this.media.getStartTime() && this.media.getEndTime())); return html` ${ViewMediaClassifier.isEvent(this.media) ? html`` : ViewMediaClassifier.isRecording(this.media) ? html`` : html``} ${this.show_favorite_control && this.media && this.hass ? html` { stopEventFromActivatingCardWideActions(ev); if (this.hass && this.media) { try { await this.cameraManager?.favoriteMedia( this.hass, this.media, !this.media?.isFavorite(), ); } catch (e) { errorToConsole(e as Error); return; } this.requestUpdate(); } }} />` : ``} ${this.details && ViewMediaClassifier.isEvent(this.media) ? html`` : this.details && ViewMediaClassifier.isRecording(this.media) ? html`` : html``} ${shouldShowTimelineControl ? html` { stopEventFromActivatingCardWideActions(ev); if (!this.view || !this.media) { return; } if (ViewMediaClassifier.isEvent(this.media)) { this.view .evolve({ view: 'timeline', queryResults: this.view.queryResults ?.clone() .selectResultIfFound((media) => media === this.media), }) .removeContext('timeline') .dispatchChangeEvent(this); } else if (ViewMediaClassifier.isRecording(this.media)) { const startTime = this.media.getStartTime(); const endTime = this.media.getStartTime(); if (!startTime || !endTime) { return; } // 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', query: null, }) .mergeInContext({ timeline: { window: { start: startTime, end: endTime, }, }, }) .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; } }