diff --git a/.vscode/i18n-ally-reviews.yml b/.vscode/i18n-ally-reviews.yml index e79af03c..07f3a096 100644 --- a/.vscode/i18n-ally-reviews.yml +++ b/.vscode/i18n-ally-reviews.yml @@ -1,3 +1,10 @@ # Review comments generated by i18n-ally. Please commit this file. reviews: + error.failed_retain: + locales: + pt-BR: + translation_candidate: + source: en + text: Não foi possível reter o evento + time: '2022-06-28T05:18:48.431Z' diff --git a/README.md b/README.md index 56d96d42..81310a26 100644 --- a/README.md +++ b/README.md @@ -1042,6 +1042,13 @@ A dashboard with the card can be cast onto a suitable device (such as the Nest H Card on Nest Hub +### Event starring + +Retain interesting Frigate events forever: + +Retain events + + ## Examples ### Illustrative Expanded Configuration Reference diff --git a/images/star.gif b/images/star.gif new file mode 100644 index 00000000..4d04777f Binary files /dev/null and b/images/star.gif differ diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 0204338a..1b347551 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -86,6 +86,7 @@ export class FrigateCardGallery extends LitElement { .hass=${this.hass} .view=${this.view} .galleryConfig=${this.galleryConfig} + .cameras=${this.cameras} > `; @@ -116,6 +117,9 @@ export class FrigateCardGalleryCore extends LitElement { @property({ attribute: false }) public galleryConfig?: GalleryConfig; + @property({ attribute: false }) + public cameras?: Map; + protected _resizeObserver: ResizeObserver; constructor() { @@ -207,11 +211,13 @@ export class FrigateCardGalleryCore extends LitElement { !this.view || !this.view.target || !this.view.target.children || - !(this.view.is('clips') || this.view.is('snapshots')) + !(this.view.is('clips') || this.view.is('snapshots')) || + !this.cameras ) { return html``; } + const cameraConfig = this.cameras.get(this.view.camera); return html` ${this._showBackArrow() ? html` { diff --git a/src/components/live.ts b/src/components/live.ts index c017d908..35006e6e 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -146,6 +146,7 @@ export class FrigateCardLive extends LitElement { .view=${this.view} .config=${config.controls.thumbnails} .browseMediaParams=${browseMediaParams ?? undefined} + .cameras=${this.cameras} ?fetch=${!this._preloaded} > ; + /** * Fetch thumbnail media when a target is not specified in the view (e.g. for * the live view). @@ -137,10 +141,12 @@ export class FrigateCardSurround extends LitElement { ${this.config && this.config.mode !== 'none' ? html` changeDrawer(ev, 'close')} @frigate-card:carousel:tap=${(ev: CustomEvent) => { // Send the view change from the source of the tap event, so the diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 82920299..588dc4f6 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,10 +1,11 @@ +import { HomeAssistant } from 'custom-card-helpers'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, html, PropertyValues, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; -import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; +import type { CameraConfig, FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { isTrueMedia } from '../utils/ha/browse-media'; @@ -20,6 +21,9 @@ export interface ThumbnailCarouselTap { @customElement('frigate-card-thumbnail-carousel') export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { + @property({ attribute: false }) + public hass?: HomeAssistant; + @property({ attribute: false }) public view?: Readonly; @@ -28,6 +32,9 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { @property({ attribute: false, hasChanged: contentsChanged }) public target?: FrigateBrowseMediaSource | null; + @property({ attribute: false }) + public cameras?: Map; + // Thumbnail carousels can expand (e.g. drawer-based carousels after the main // media loads). The carousel must be re-initialized in these cases, or the // dynamic sizing fails (and users can scroll past the end of the carousel). @@ -195,10 +202,13 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { 'slide-selected': this._selected === childIndex, }; + const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null; return html` `} - ${this.controls && event?.retain_indefinitely + ${this.controls && 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 @@ -255,11 +292,11 @@ export class FrigateCardThumbnail extends LitElement { } 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 - } + 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; + } } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index ace18398..7fa68f7d 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -450,6 +450,7 @@ export class FrigateCardTimeline extends LitElement { .hass=${this.hass} .view=${this.view} .config=${this.timelineConfig.controls.thumbnails} + .cameras=${this.cameras} > o[i], languages['en']); } - if (translated === undefined) { + if (!translated) { translated = string.split('.').reduce((o, i) => o[i], languages['en']); } diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 250282a9..caa64791 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -31,16 +31,27 @@ ha-icon { position: absolute; border-radius: 50%; - opacity: 0.8; + opacity: 0.5; + color: var(--primary-color); + top: 3px; + + cursor: pointer; + + transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; +} +ha-icon:hover { + opacity: 1.0; } -ha-icon.favorite { +ha-icon.star { + left: 3px; +} +ha-icon.star.starred { color: gold; } ha-icon.timeline { - color: var(--primary-color); - right: 0px; + right: 3px; } frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording { diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index 47e9e368..ebb226a2 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -1,6 +1,8 @@ +import { HomeAssistant } from 'custom-card-helpers'; import { z } from 'zod'; -import { CameraConfig, ExtendedHomeAssistant } from '../types'; -import { homeAssistantHTTPRequest } from './ha'; +import { localize } from '../localize/localize'; +import { CameraConfig, ExtendedHomeAssistant, FrigateCardError } from '../types'; +import { homeAssistantHTTPRequest, homeAssistantWSRequest } from './ha'; export const FRIGATE_ICON_SVG_PATH = 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + @@ -50,6 +52,12 @@ const recordingSegmentSchema = z.object({ const recordingSegmentsSchema = recordingSegmentSchema.array(); export type RecordingSegments = z.infer; +const retainResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type RetainResult = z.infer; + /** * Get the recordings summary. May throw. * @param hass The Home Assistant object. @@ -96,6 +104,38 @@ export const getRecordingSegments = async ( ); }; +/** + * Request that Frigate retain an event. May throw. + * @param hass The HomeAssistant object. + * @param client_id The Frigate client_id. + * @param eventID The event ID to retain. + * @param retain `true` to retain or `false` to unretain. + */ +export async function retainEvent( + hass: HomeAssistant, + client_id: string, + eventID: string, + retain: boolean, +): Promise { + const retainRequest = { + type: 'frigate/event/retain', + instance_id: client_id, + event_id: eventID, + retain: retain, + }; + const response = await homeAssistantWSRequest( + hass, + retainResultSchema, + retainRequest, + ); + if (!response.success) { + throw new FrigateCardError(localize('error.failed_retain'), { + request: retainRequest, + response: response, + }); + } +} + /** * Get an id that unique identifies a particular camera (not zone, object, etc) * within a particular Frigate instance. ID will not (necessarily) be unique