Merge pull request #779 from dermotduffy/thumbnails-over-http

Add support for fetching thumbnails separately
This commit is contained in:
Dermot Duffy
2022-07-31 19:17:47 -07:00
committed by GitHub
7 changed files with 170 additions and 58 deletions
+3 -3
View File
@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { HomeAssistant } from 'custom-card-helpers';
import { import {
css, css,
CSSResultGroup, CSSResultGroup,
@@ -13,6 +12,7 @@ import { customElement, property } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { import {
CameraConfig, CameraConfig,
ExtendedHomeAssistant,
frigateCardConfigDefaults, frigateCardConfigDefaults,
GalleryConfig, GalleryConfig,
THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MAX,
@@ -31,7 +31,7 @@ import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
@customElement('frigate-card-gallery') @customElement('frigate-card-gallery')
export class FrigateCardGallery extends LitElement { export class FrigateCardGallery extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: HomeAssistant; public hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
@@ -109,7 +109,7 @@ export class FrigateCardGallery extends LitElement {
@customElement('frigate-card-gallery-core') @customElement('frigate-card-gallery-core')
export class FrigateCardGalleryCore extends LitElement { export class FrigateCardGalleryCore extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: HomeAssistant; public hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
+13
View File
@@ -138,6 +138,19 @@ export class FrigateCardLive extends LitElement {
} }
} }
/**
* Determine whether the element should be updated.
* @param _changedProps The changed properties if any.
* @returns `true` if the element should be updated.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_changedProps: PropertyValues): boolean {
// Don't process updates if it's in the background and a message was
// received (otherwise an error message thrown by the background live
// component may continually be re-spammed hitting performance).
return !this._inBackground || !this._messageReceivedPostRender;
}
/** /**
* Component connected callback. * Component connected callback.
*/ */
+2 -2
View File
@@ -1,4 +1,3 @@
import { HomeAssistant } from 'custom-card-helpers';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
@@ -12,6 +11,7 @@ import surroundThumbnailsStyle from '../scss/surround.scss';
import { import {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
CameraConfig, CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
FrigateCardError, FrigateCardError,
FrigateCardView, FrigateCardView,
@@ -30,7 +30,7 @@ import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
@customElement('frigate-card-surround-thumbnails') @customElement('frigate-card-surround-thumbnails')
export class FrigateCardSurround extends LitElement { export class FrigateCardSurround extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: HomeAssistant; public hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
+2 -2
View File
@@ -1,4 +1,3 @@
import { HomeAssistant } from 'custom-card-helpers';
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { import {
@@ -15,6 +14,7 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { import {
CameraConfig, CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
ThumbnailsControlConfig, ThumbnailsControlConfig,
} from '../types.js'; } from '../types.js';
@@ -36,7 +36,7 @@ export interface ThumbnailCarouselTap {
@customElement('frigate-card-thumbnail-carousel') @customElement('frigate-card-thumbnail-carousel')
export class FrigateCardThumbnailCarousel extends LitElement { export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: HomeAssistant; public hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
+57 -15
View File
@@ -1,4 +1,4 @@
import { HomeAssistant } from 'custom-card-helpers'; import { Task } from '@lit-labs/task/task.js';
import { format, fromUnixTime } from 'date-fns'; import { format, fromUnixTime } from 'date-fns';
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
@@ -9,6 +9,7 @@ import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailStyle from '../scss/thumbnail.scss'; import thumbnailStyle from '../scss/thumbnail.scss';
import type { import type {
ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
FrigateEvent, FrigateEvent,
FrigateRecording, FrigateRecording,
@@ -17,7 +18,9 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js';
import { retainEvent } from '../utils/frigate.js'; import { retainEvent } from '../utils/frigate.js';
import { getEventDurationString } from '../utils/ha/browse-media.js'; import { getEventDurationString } from '../utils/ha/browse-media.js';
import { homeAssistantSignPath } from '../utils/ha/index.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { dispatchFrigateCardErrorEvent, renderProgressIndicator } from './message.js';
// The minimum width of a thumbnail with details enabled. // The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@@ -27,10 +30,48 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public thumbnail?: string; public thumbnail?: string;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
protected _signThumbnailTask = new Task(
this,
this._signThumbnail.bind(this),
// Do not re-run the task if hass changes, unless it was previously undefined.
(): [boolean, string | undefined] => [!!this.hass, this.thumbnail],
);
/**
* Sign a thumbnail URL if necessary. May throw.
* @param param0 A list of lit-task dependencies.
* @returns A signed URL or null.
*/
protected async _signThumbnail([haveHASS, thumbnail]: [
boolean,
string | undefined,
]): Promise<string | null> {
if (!haveHASS || !this.hass || !thumbnail) {
return null;
}
if (this.thumbnail?.startsWith('data:')) {
return this.thumbnail;
}
return await homeAssistantSignPath(this.hass, thumbnail);
}
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
return html` return html`
${this.thumbnail ${this.thumbnail
? html`<img src="${this.thumbnail}" />` ? html` ${this._signThumbnailTask.render({
initial: () => renderProgressIndicator(),
pending: () => renderProgressIndicator(),
error: (e: unknown) => {
errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(this, e as Error);
},
complete: (signedThumbnail: string | null) => {
return signedThumbnail ? html`<img src="${signedThumbnail}" />` : html``;
},
})}`
: html`<ha-icon : html`<ha-icon
icon="mdi:image-off" icon="mdi:image-off"
title=${localize('thumbnail.no_thumbnail')} title=${localize('thumbnail.no_thumbnail')}
@@ -134,35 +175,35 @@ export class FrigateCardThumbnail extends LitElement {
@property({ attribute: true, type: Boolean }) @property({ attribute: true, type: Boolean })
public show_timeline_control = false; public show_timeline_control = false;
// ============================ // ======================
// Data-binding based interface // Target-based interface
// ============================ // ======================
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false }) @property({ attribute: false })
public target?: FrigateBrowseMediaSource | null; public target?: FrigateBrowseMediaSource | null;
@property({ attribute: false }) @property({ attribute: false })
public childIndex?: number; public childIndex?: number;
// ============================================================= // ===================================================
// Overrides that can be used if data bindings are not available // Raw interface (can override target-based interface)
// ============================================================= // ===================================================
@property({ attribute: true }) @property({ attribute: true })
public thumbnail?: string; public thumbnail?: string;
@property({ attribute: true }) @property({ attribute: true })
public label?: string; public label?: string;
@property({ attribute: true }) @property({ attribute: false })
public event?: string; public event?: FrigateEvent;
// ================================ // ================================
// Optional parameters for controls // Optional parameters for controls
// ================================ // ================================
@property({ attribute: false }) @property({ attribute: false })
public hass?: HomeAssistant; public view?: Readonly<View>;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public clientID?: string; public clientID?: string;
@@ -188,7 +229,7 @@ export class FrigateCardThumbnail extends LitElement {
// Always give the overrides preference (if specified). // Always give the overrides preference (if specified).
if (this.event) { if (this.event) {
event = JSON.parse(this.event); event = this.event;
} }
thumbnail = this.thumbnail ? this.thumbnail : thumbnail; thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
label = this.label ? this.label : label; label = this.label ? this.label : label;
@@ -206,6 +247,7 @@ export class FrigateCardThumbnail extends LitElement {
? html`<frigate-card-thumbnail-feature-event ? html`<frigate-card-thumbnail-feature-event
aria-label="${label ?? ''}" aria-label="${label ?? ''}"
title="${label ?? ''}" title="${label ?? ''}"
.hass=${this.hass}
.thumbnail=${thumbnail ?? undefined} .thumbnail=${thumbnail ?? undefined}
.label=${label ?? undefined} .label=${label ?? undefined}
></frigate-card-thumbnail-feature-event>` ></frigate-card-thumbnail-feature-event>`
+90 -34
View File
@@ -96,7 +96,10 @@ interface CameraRecordings {
summary: RecordingSummary; summary: RecordingSummary;
} }
const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)'); // An event used to fetch the HASS object. See "Special note" below.
class HASSRequestEvent extends Event {
public hass?: ExtendedHomeAssistant;
}
/** /**
* A manager to maintain/fetch timeline events. * A manager to maintain/fetch timeline events.
@@ -117,17 +120,6 @@ class TimelineDataManager {
// than this). // than this).
protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS; protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
constructor(params?: {
contentCallback?: (source: FrigateBrowseMediaSource) => string;
tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
}) {
this._contentCallback = params?.contentCallback;
this._tooltipCallback = params?.tooltipCallback;
}
// Get the last event fetch date. // Get the last event fetch date.
get lastFetchDate(): Date | null { get lastFetchDate(): Date | null {
return this._dateFetch ?? null; return this._dateFetch ?? null;
@@ -178,8 +170,7 @@ class TimelineDataManager {
item = { item = {
id: event.id, id: event.id,
group: camera, group: camera,
content: this._contentCallback?.(child) ?? '', content: '',
title: this._tooltipCallback?.(child) ?? '',
start: event.start_time * 1000, start: event.start_time * 1000,
event: event, event: event,
}; };
@@ -423,6 +414,62 @@ class TimelineDataManager {
} }
} }
/**
* A simgple thumbnail wrapper class for use in the timeline where LIT data
* bindings are not available.
*/
@customElement('frigate-card-timeline-thumbnail')
export class FrigateCardTimelineThumbnail extends LitElement {
@property({ attribute: true })
public thumbnail?: string;
@property({ attribute: true, type: Boolean })
public details = false;
@property({ attribute: true })
public event?: string;
@property({ attribute: true })
public label?: string;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
// Don't display tooltips on touch devices, they just get in the way of
// the drawer.
if (!this.thumbnail || !this.event) {
return html``;
}
/* Special note on what's going on here:
*
* This component does not have access to HASS, as there's no way to pass it
* in via the string-based tooltip that timeline supports. Instead dispatch
* an event to request HASS which the timeline adds to the event object
* before execution continues.
*/
const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, {
composed: true,
bubbles: true,
});
this.dispatchEvent(hassRequest);
if (!hassRequest.hass) {
return html``;
}
return html` <frigate-card-thumbnail
.hass=${hassRequest.hass}
.event=${JSON.parse(this.event)}
.label=${this.label}
.thumbnail=${this.thumbnail}
?details=${this.details}
>
</frigate-card-thumbnail>`;
}
}
@customElement('frigate-card-timeline') @customElement('frigate-card-timeline')
export class FrigateCardTimeline extends LitElement { export class FrigateCardTimeline extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -484,9 +531,8 @@ export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public timelineConfig?: TimelineConfig; public timelineConfig?: TimelineConfig;
protected _data = new TimelineDataManager({ protected _data = new TimelineDataManager();
tooltipCallback: this._getTooltip.bind(this),
});
protected _refTimeline: Ref<HTMLElement> = createRef(); protected _refTimeline: Ref<HTMLElement> = createRef();
protected _timeline?: Timeline; protected _timeline?: Timeline;
@@ -495,13 +541,18 @@ export class FrigateCardTimelineCore extends LitElement {
protected _pointerHeld = false; protected _pointerHeld = false;
protected _ignoreClick = false; protected _ignoreClick = false;
protected static _isHoverableDevice = window.matchMedia(
'(hover: hover) and (pointer: fine)',
).matches;
/** /**
* Get a tooltip for a given timeline event. * Get a tooltip for a given timeline event.
* @param source The FrigateBrowseMediaSource in question. * @param source The FrigateBrowseMediaSource in question.
* @returns The tooltip as a string to render. * @returns The tooltip as a string to render.
*/ */
protected _getTooltip(source: FrigateBrowseMediaSource): string { protected _getTooltip(item: TimelineItem): string {
if (!isHoverableDevice.matches) { const source = (<FrigateCardTimelineItem>item).source;
if (!FrigateCardTimelineCore._isHoverableDevice || !source) {
// Don't display tooltips on touch devices, they just get in the way of // Don't display tooltips on touch devices, they just get in the way of
// the drawer. // the drawer.
return ''; return '';
@@ -518,13 +569,13 @@ export class FrigateCardTimelineCore extends LitElement {
// Note that changes to attributes here must be mirrored in the xss // Note that changes to attributes here must be mirrored in the xss
// whitelist in `_getOptions()` . // whitelist in `_getOptions()` .
return ` return `
<frigate-card-thumbnail <frigate-card-timeline-thumbnail
${detailsAttr}
thumbnail="${source.thumbnail}" thumbnail="${source.thumbnail}"
label="${source.title}" ${detailsAttr}
${eventAttr} ${eventAttr}
label="${source.title}"
> >
</frigate-card-thumbnail>`; </frigate-card-timeline-thumbnail>`;
} }
/** /**
@@ -544,6 +595,9 @@ export class FrigateCardTimelineCore extends LitElement {
}; };
return html`<div return html`<div
@frigate-card:timeline:hass-request=${(request: HASSRequestEvent) => {
request.hass = this.hass;
}}
class="${classMap(timelineClasses)}" class="${classMap(timelineClasses)}"
${ref(this._refTimeline)} ${ref(this._refTimeline)}
></div>`; ></div>`;
@@ -735,7 +789,7 @@ export class FrigateCardTimelineCore extends LitElement {
properties: TimelineEventPropertiesResult, properties: TimelineEventPropertiesResult,
): void { ): void {
if (properties.event && this._pointerHeld) { if (properties.event && this._pointerHeld) {
// An event will have been set when it's a human changes the range, // An event will have been set when it's a human changes the range.
this._ignoreClick = true; this._ignoreClick = true;
} }
} }
@@ -1052,12 +1106,18 @@ export class FrigateCardTimelineCore extends LitElement {
tooltip: { tooltip: {
followMouse: true, followMouse: true,
overflowMethod: 'cap', overflowMethod: 'cap',
template: this._getTooltip.bind(this),
}, },
xss: { xss: {
disabled: false, disabled: false,
filterOptions: { filterOptions: {
whiteList: { whiteList: {
'frigate-card-thumbnail': ['details', 'thumbnail', 'label', 'event'], 'frigate-card-timeline-thumbnail': [
'details',
'thumbnail',
'label',
'event',
],
div: ['title'], div: ['title'],
span: ['style'], span: ['style'],
}, },
@@ -1229,14 +1289,9 @@ export class FrigateCardTimelineCore extends LitElement {
// Don't show an empty timeline, show a message instead. // Don't show an empty timeline, show a message instead.
const groups = this._getGroups(); const groups = this._getGroups();
if (!groups.length) { if (!groups.length) {
dispatchMessageEvent( dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', {
this,
localize('error.timeline_no_cameras'),
'info',
{
icon: 'mdi:chart-gantt', icon: 'mdi:chart-gantt',
} });
);
return; return;
} }
@@ -1257,10 +1312,10 @@ export class FrigateCardTimelineCore extends LitElement {
this._timeline.on('mouseDown', () => { this._timeline.on('mouseDown', () => {
this._pointerHeld = true; this._pointerHeld = true;
this._ignoreClick = false; this._ignoreClick = false;
}) });
this._timeline.on('mouseUp', () => { this._timeline.on('mouseUp', () => {
this._pointerHeld = false; this._pointerHeld = false;
}) });
} }
} }
@@ -1279,6 +1334,7 @@ export class FrigateCardTimelineCore extends LitElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'frigate-card-timeline-thumbnail': FrigateCardTimelineThumbnail;
'frigate-card-timeline-core': FrigateCardTimelineCore; 'frigate-card-timeline-core': FrigateCardTimelineCore;
'frigate-card-timeline': FrigateCardTimeline; 'frigate-card-timeline': FrigateCardTimeline;
} }
+2 -1
View File
@@ -8,7 +8,8 @@ img {
} }
img, img,
ha-icon { ha-icon,
frigate-card-progress-indicator {
border-radius: var(--ha-card-border-radius, 4px); border-radius: var(--ha-card-border-radius, 4px);
max-width: var(--frigate-card-thumbnail-size); max-width: var(--frigate-card-thumbnail-size);