Merge pull request #152 from dermotduffy/swipe2

Re-implement the media viewer as a swipe carousel
This commit is contained in:
Dermot Duffy
2021-10-26 14:44:35 -07:00
committed by GitHub
14 changed files with 705 additions and 208 deletions
View File
+1 -1
View File
@@ -83,7 +83,7 @@ lovelace:
| `frigate_url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. | | `frigate_url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. |
| `autoplay_clip` | `false` | Whether or not to autoplay clips in the 'clip' [view](#views). Clips manually chosen in the clips gallery will still autoplay.| | `autoplay_clip` | `false` | Whether or not to autoplay clips in the 'clip' [view](#views). Clips manually chosen in the clips gallery will still autoplay.|
| `live_preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background so it's instantly available when requested. This consumes additional network/CPU resources continually.| | `live_preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background so it's instantly available when requested. This consumes additional network/CPU resources continually.|
| `event_viewer.lazy_load` | `true` | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. |
#### Live Provider #### Live Provider
+2
View File
@@ -19,8 +19,10 @@
"@material/image-list": "^12.0.0", "@material/image-list": "^12.0.0",
"custom-card-helpers": "^1.8.0", "custom-card-helpers": "^1.8.0",
"dayjs": "^1.10.7", "dayjs": "^1.10.7",
"embla-carousel": "^5.0.1",
"home-assistant-js-websocket": "^5.11.1", "home-assistant-js-websocket": "^5.11.1",
"lit": "^2.0.2", "lit": "^2.0.2",
"quick-lru": "github:sindresorhus/quick-lru",
"screenfull": "^5.1.0", "screenfull": "^5.1.0",
"zod": "^3.10.0" "zod": "^3.10.0"
}, },
+28 -7
View File
@@ -51,6 +51,7 @@ import './patches/ha-camera-stream.js';
import './patches/ha-hls-player.js'; import './patches/ha-hls-player.js';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { ResolvedMediaCache } from './resolved-media.js';
const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_HEIGHT_CUTOFF = 50;
const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF;
@@ -133,6 +134,9 @@ export class FrigateCard extends LitElement {
// Error/info message to render. // Error/info message to render.
protected _message: Message | null = null; protected _message: Message | null = null;
// A cache of resolved media URLs/mimetypes for use in the whole card.
protected _resolvedMediaCache = new ResolvedMediaCache();
set hass(hass: HomeAssistant & ExtendedHomeAssistant) { set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
this._hass = hass; this._hass = hass;
@@ -485,13 +489,19 @@ export class FrigateCard extends LitElement {
`; `;
} }
protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters | null { /**
if (!this._frigateCameraName) { * Get the parameters to search for media related to the current view.
return null; * @returns A BrowseMediaQueryParameters object.
*/
protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters | undefined {
if (
!this._frigateCameraName ||
!(this._view.isClipRelatedView() || this._view.isSnapshotRelatedView())
) {
return undefined;
} }
return { return {
mediaType: this._view.view == 'clips' ? 'clips' : 'snapshots', mediaType: this._view.isClipRelatedView() ? 'clips' : 'snapshots',
clientId: this.config.frigate_client_id, clientId: this.config.frigate_client_id,
cameraName: this._frigateCameraName, cameraName: this._frigateCameraName,
label: this.config.label, label: this.config.label,
@@ -499,10 +509,16 @@ export class FrigateCard extends LitElement {
}; };
} }
/**
* Handler for media play event.
*/
protected _playHandler(): void { protected _playHandler(): void {
this._mediaPlaying = true; this._mediaPlaying = true;
} }
/**
* Handler for media pause event.
*/
protected _pauseHandler(): void { protected _pauseHandler(): void {
this._mediaPlaying = false; this._mediaPlaying = false;
} }
@@ -665,8 +681,10 @@ export class FrigateCard extends LitElement {
} }
protected _render(): TemplateResult | void { protected _render(): TemplateResult | void {
const mediaQueryParameters = this._getBrowseMediaQueryParameters(); if (!this._hass) {
if (!this._hass || !this._frigateCameraName || !mediaQueryParameters) { return html``;
}
if (!this._frigateCameraName) {
this._setMessageAndUpdate( this._setMessageAndUpdate(
{ {
message: localize('error.no_frigate_camera_name'), message: localize('error.no_frigate_camera_name'),
@@ -675,6 +693,7 @@ export class FrigateCard extends LitElement {
true, true,
); );
} }
const mediaQueryParameters = this._getBrowseMediaQueryParameters();
const pictureElementsClasses = { const pictureElementsClasses = {
'picture-elements': true, 'picture-elements': true,
@@ -724,6 +743,8 @@ export class FrigateCard extends LitElement {
.browseMediaQueryParameters=${mediaQueryParameters} .browseMediaQueryParameters=${mediaQueryParameters}
.nextPreviousControlStyle=${this.config.controls?.nextprev ?? 'thumbnails'} .nextPreviousControlStyle=${this.config.controls?.nextprev ?? 'thumbnails'}
.autoplayClip=${this.config.autoplay_clip} .autoplayClip=${this.config.autoplay_clip}
.resolvedMediaCache=${this._resolvedMediaCache}
.lazyLoad=${this.config.event_viewer?.lazy_load ?? true}
class="${classMap(viewerClasses)}" class="${classMap(viewerClasses)}"
@frigate-card:change-view=${this._changeViewHandler} @frigate-card:change-view=${this._changeViewHandler}
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-load=${this._mediaLoadHandler}
+5 -1
View File
@@ -43,6 +43,10 @@ export async function homeAssistantWSRequest<T>(
return parseResult.data; return parseResult.data;
} }
export function isTrueMedia(media: BrowseMediaSource): boolean {
return !media.can_expand;
}
// From a BrowseMediaSource item extract the first true media item (i.e. a // From a BrowseMediaSource item extract the first true media item (i.e. a
// clip/snapshot, not a folder). // clip/snapshot, not a folder).
export function getFirstTrueMediaChildIndex( export function getFirstTrueMediaChildIndex(
@@ -52,7 +56,7 @@ export function getFirstTrueMediaChildIndex(
return null; return null;
} }
for (let i = 0; i < media.children.length; i++) { for (let i = 0; i < media.children.length; i++) {
if (!media.children[i].can_expand) { if (isTrueMedia(media.children[i])) {
return i; return i;
} }
} }
+5 -1
View File
@@ -36,7 +36,7 @@ export class FrigateCardGallery extends LitElement {
protected view!: View; protected view!: View;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaQueryParameters!: BrowseMediaQueryParameters; protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
protected _resizeObserver: ResizeObserver; protected _resizeObserver: ResizeObserver;
@@ -72,6 +72,10 @@ export class FrigateCardGallery extends LitElement {
} }
protected async _render(): Promise<TemplateResult | void> { protected async _render(): Promise<TemplateResult | void> {
if (!this.browseMediaQueryParameters) {
return html``;
}
let parent: BrowseMediaSource | null; let parent: BrowseMediaSource | null;
try { try {
if (this.view.target) { if (this.view.target) {
+17 -39
View File
@@ -2,72 +2,50 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { BrowseMediaSource, NextPreviousControlStyle } from '../types.js'; import { NextPreviousControlStyle } from '../types.js';
import { View } from '../view.js';
import controlStyle from '../scss/next-previous-control.scss'; import controlStyle from '../scss/next-previous-control.scss';
@customElement('frigate-card-next-previous-control') @customElement('frigate-card-next-previous-control')
export class FrigateCardMessage extends LitElement { export class FrigateCardMessage extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected control!: "next" | "previous"; protected direction?: 'next' | 'previous';
@property({ attribute: false }) @property({ attribute: false })
protected controlStyle!: NextPreviousControlStyle; protected controlStyle?: NextPreviousControlStyle;
@property({ attribute: false }) @property({ attribute: false })
protected parent!: BrowseMediaSource; protected thumbnail?: string;
@property({ attribute: false }) protected render(): TemplateResult {
protected childIndex!: number; if (!this.controlStyle || this.controlStyle == 'none') {
@property({ attribute: false })
protected view!: View;
protected _changeView(): void {
new View({
view: this.view.view,
target: this.parent,
childIndex: this.childIndex,
}).dispatchChangeEvent(this);
}
protected render() : TemplateResult {
if (this.controlStyle == 'none' || !this.parent.children) {
return html``;
}
const target = this.parent.children[this.childIndex];
if (!target) {
return html``; return html``;
} }
const classes = { const classes = {
controls: true, controls: true,
previous: this.control == "previous", previous: this.direction == 'previous',
next: this.control == "next", next: this.direction == 'next',
thumbnails: this.controlStyle == "thumbnails", thumbnails: this.controlStyle == 'thumbnails',
chevrons: this.controlStyle == "chevrons", chevrons: this.controlStyle == 'chevrons',
button: this.controlStyle == "chevrons", button: this.controlStyle == 'chevrons',
}; };
if (this.controlStyle == "chevrons") { if (this.controlStyle == 'chevrons') {
return html` <ha-icon-button return html` <ha-icon-button
icon=${this.control == "previous" ? 'mdi:chevron-left' : 'mdi:chevron-right'} icon=${this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'}
class="${classMap(classes)}" class="${classMap(classes)}"
title=${target.title} title=${this.title}
@click=${this._changeView}
></ha-icon-button>`; ></ha-icon-button>`;
} }
if (!target.thumbnail) { if (!this.thumbnail) {
return html``; return html``;
} }
return html`<img return html`<img
src="${target.thumbnail}" src="${this.thumbnail}"
class="${classMap(classes)}" class="${classMap(classes)}"
title="${target.title}" title="${this.title}"
@click=${this._changeView}
/>`; />`;
} }
+472 -125
View File
@@ -1,20 +1,28 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import {
CSSResultGroup,
LitElement,
TemplateResult,
html,
unsafeCSS,
PropertyValues,
} from 'lit';
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
import { HomeAssistant } from 'custom-card-helpers'; import { ifDefined } from 'lit-html/directives/if-defined.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js';
import { resolvedMediaSchema } from '../types.js';
import type { import type {
BrowseMediaNeighbors, BrowseMediaNeighbors,
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
BrowseMediaSource, BrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
NextPreviousControlStyle, NextPreviousControlStyle,
ResolvedMedia,
} from '../types.js'; } from '../types.js';
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { import {
browseMediaQuery, browseMediaQuery,
@@ -24,51 +32,224 @@ import {
dispatchPauseEvent, dispatchPauseEvent,
dispatchPlayEvent, dispatchPlayEvent,
getFirstTrueMediaChildIndex, getFirstTrueMediaChildIndex,
homeAssistantWSRequest, isTrueMedia,
} from '../common.js'; } from '../common.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { import { renderProgressIndicator } from '../components/message.js';
renderProgressIndicator,
} from '../components/message.js';
import './next-prev-control.js'; import './next-prev-control.js';
import viewerStyle from '../scss/viewer.scss'; import viewerStyle from '../scss/viewer.scss';
const IMG_TRANSPARENT_1x1 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
// Load dayjs plugin(s). // Load dayjs plugin(s).
dayjs.extend(dayjs_custom_parse_format); dayjs.extend(dayjs_custom_parse_format);
@customElement('frigate-card-viewer') @customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement { export class FrigateCardViewer extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected hass!: HomeAssistant & ExtendedHomeAssistant; protected hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected view!: View; protected view?: View;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaQueryParameters!: BrowseMediaQueryParameters; protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
@property({ attribute: false }) @property({ attribute: false })
protected nextPreviousControlStyle!: NextPreviousControlStyle; protected nextPreviousControlStyle?: NextPreviousControlStyle;
@property({ attribute: false }) @property({ attribute: false })
protected autoplayClip!: boolean; protected autoplayClip?: boolean;
protected async _resolveMedia( @property({ attribute: false })
mediaSource: BrowseMediaSource | null, protected resolvedMediaCache?: ResolvedMediaCache;
): Promise<ResolvedMedia | null> {
if (!mediaSource) { @property({ attribute: false })
return null; protected lazyLoad?: boolean;
}
const request = { protected render(): TemplateResult | void {
type: 'media_source/resolve_media', return html`${until(this._render(), renderProgressIndicator())}`;
media_content_id: mediaSource.media_content_id,
};
return homeAssistantWSRequest(this.hass, resolvedMediaSchema, request);
} }
/**
* Resolve all the given media for a target.
* @param target The target to resolve media from.
* @returns True if the resolutions were all error free.
*/
protected async _resolveAllMediaForTarget(
target: BrowseMediaSource,
): Promise<boolean> {
if (!this.hass) {
return false;
}
let errorFree = true;
for (let i = 0; target.children && i < (target.children || []).length; ++i) {
if (isTrueMedia(target.children[i])) {
errorFree &&= !!(await ResolvedMediaUtil.resolveMedia(
this.hass,
target.children[i],
this.resolvedMediaCache,
));
}
}
return errorFree;
}
/**
* Asyncronously render the element.
* @returns A template to render.
*/
protected async _render(): Promise<TemplateResult | void> {
if (!this.hass || !this.view || !this.browseMediaQueryParameters) {
return html``;
}
let autoplay = true;
let view = this.view;
if (!view.target) {
let parent: BrowseMediaSource | null = null;
try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message);
}
const childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent(
this,
this.view.is('clip')
? localize('common.no_clip')
: localize('common.no_snapshot'),
this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
}
view = new View({
view: this.view.view,
target: parent,
childIndex: childIndex,
});
// In this block, no clip has been manually selected, so this is loading
// the most recent clip on card load. In this mode, autoplay of the clip
// may be disabled by configuration. If does not make sense to disable
// autoplay when the user has explicitly picked an event to play in the
// gallery.
autoplay = this.autoplayClip ?? true;
}
if (view.target && !(await this._resolveAllMediaForTarget(view.target))) {
return dispatchErrorMessageEvent(this, localize('error.could_not_resolve'));
}
return html` <frigate-card-viewer-core
.view=${view}
.nextPreviousControlStyle=${this.nextPreviousControlStyle}
.resolvedMediaCache=${this.resolvedMediaCache}
.autoplayClip=${autoplay}
.hass=${this.hass}
.browseMediaQueryParameters=${this.browseMediaQueryParameters}
.lazyLoad=${this.lazyLoad}
>
</frigate-card-viewer-core>`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(viewerStyle);
}
}
@customElement('frigate-card-viewer-core')
export class FrigateCardViewerCore extends LitElement {
@property({ attribute: false })
protected view?: View;
@property({ attribute: false })
protected nextPreviousControlStyle?: NextPreviousControlStyle;
@property({ attribute: false })
protected resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
protected autoplayClip?: boolean;
@property({ attribute: false })
protected hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false })
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
@property({ attribute: false })
protected lazyLoad?: boolean;
// Media carousel object.
protected _carousel?: EmblaCarouselType;
protected _loadedCarousel = false;
// Mapping of slide # to BrowseMediaSource child #.
// (Folders are not media items that can be rendered).
protected _slideToChild: Record<number, number> = {};
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!this._loadedCarousel) {
this.updateComplete.then(() => {
this._loadCarousel();
});
}
}
/**
* Load the carousel with "slides" (clips or snapshots).
*/
protected _loadCarousel(): void {
const carouselNode = this.renderRoot.querySelector(
'.embla__viewport',
) as HTMLElement;
if (carouselNode) {
this._loadedCarousel = true;
// Start the carousel on the selected child number.
const startIndex = Number(
Object.keys(this._slideToChild).find(
(key) => this._slideToChild[key] === this.view?.childIndex,
),
);
this._carousel = EmblaCarousel(carouselNode, {
startIndex: isNaN(startIndex) ? undefined : startIndex,
});
// Update views based on slide selections.
this._carousel.on('select', this._slideSelectHandler.bind(this));
// Lazily load media that is displayed. These handlers are registered
// regardless of the value of this.lazyLoad to allow that value to change
// after the carousel has been initialized.
this._carousel.on('init', this._lazyLoadMediaHandler.bind(this));
this._carousel.on('select', this._lazyLoadMediaHandler.bind(this));
this._carousel.on('resize', this._lazyLoadMediaHandler.bind(this));
}
}
/**
* Get the event start time from a media object.
* @param browseMedia The media object to extract the start time from.
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
protected _extractEventStartTimeFromBrowseMedia( protected _extractEventStartTimeFromBrowseMedia(
browseMedia: BrowseMediaSource, browseMedia: BrowseMediaSource,
): number | null { ): number | null {
@@ -86,20 +267,26 @@ export class FrigateCardViewer extends LitElement {
return null; return null;
} }
// Get the previous and next real media items, given the index /**
protected _getMediaNeighbors( * Get the previous and next true media items from the current view.
parent: BrowseMediaSource, * @returns A BrowseMediaNeighbors with indices and objects of true media
index: number | null, * neighbors.
): BrowseMediaNeighbors | null { */
if (index == null || !parent.children) { protected _getMediaNeighbors(): BrowseMediaNeighbors | null {
if (
!this.view ||
!this.view.target ||
!this.view.target.children ||
this.view.childIndex === undefined
) {
return null; return null;
} }
// Work backwards from the index to get the previous real media. // Work backwards from the index to get the previous real media.
let prevIndex: number | null = null; let prevIndex: number | null = null;
for (let i = index - 1; i >= 0; i--) { for (let i = this.view.childIndex - 1; i >= 0; i--) {
const media = parent.children[i]; const media = this.view.target.children[i];
if (media && !media.can_expand) { if (media && isTrueMedia(media)) {
prevIndex = i; prevIndex = i;
break; break;
} }
@@ -107,9 +294,9 @@ export class FrigateCardViewer extends LitElement {
// Work forwards from the index to get the next real media. // Work forwards from the index to get the next real media.
let nextIndex: number | null = null; let nextIndex: number | null = null;
for (let i = index + 1; i < parent.children.length; i++) { for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
const media = parent.children[i]; const media = this.view.target.children[i];
if (media && !media.can_expand) { if (media && isTrueMedia(media)) {
nextIndex = i; nextIndex = i;
break; break;
} }
@@ -117,112 +304,274 @@ export class FrigateCardViewer extends LitElement {
return { return {
previousIndex: prevIndex, previousIndex: prevIndex,
previous: prevIndex != null ? parent.children[prevIndex] : null, previous: prevIndex != null ? this.view.target.children[prevIndex] : null,
nextIndex: nextIndex, nextIndex: nextIndex,
next: nextIndex != null ? parent.children[nextIndex] : null, next: nextIndex != null ? this.view.target.children[nextIndex] : null,
}; };
} }
// Get a clip at the same time as a snapshot. /**
protected async _findRelatedClips( * Get a clip view that matches a given snapshot. Includes clips within the
snapshot: BrowseMediaSource | null, * same range as the current view.
): Promise<BrowseMediaSource | null> { * @param snapshot The snapshot to find a matching clip for.
if (!snapshot) { * @returns The view that would show the matching clip.
*/
protected async _findRelatedClipView(
snapshot: BrowseMediaSource,
): Promise<View | null> {
if (
!this.hass ||
!this.view ||
!this.view.target ||
!this.view.target.children ||
!this.view.target.children.length ||
!this.browseMediaQueryParameters
) {
return null; return null;
} }
const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot); const snapshotStartTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
if (startTime) { if (!snapshotStartTime) {
return null;
}
// Heuristic: At this point, the user has a particular snapshot that they
// are interested in and want to see a related clip, yet the viewer code
// does not know the exact search criteria that led to that snapshot (e.g.
// it could be a 10-deep folder in the gallery). To give the user to ability
// to 'navigate' in the clips view once they change into that mode, this
// heuristic finds the earliest and latest snapshot that the user is
// currently viewing and mirrors that range into the clips view. Then,
// within the results see if there's a clip that matches the same time as
// the snapshot.
let earliest: number | null = null;
let latest: number | null = null;
for (let i = 0; i < this.view.target.children.length; i++) {
const child = this.view.target.children[i];
if (!isTrueMedia(child)) {
continue;
}
const startTime = this._extractEventStartTimeFromBrowseMedia(child);
if (startTime && (earliest === null || startTime < earliest)) {
earliest = startTime;
}
if (startTime && (latest === null || startTime > latest)) {
latest = startTime;
}
}
if (!earliest || !latest) {
return null;
}
let clips: BrowseMediaSource | null;
try { try {
// Fetch clips within the same second (same camera/zone/label, etc). clips = await browseMediaQuery(this.hass, {
const clipsAtSameTime = await browseMediaQuery(this.hass, {
...this.browseMediaQueryParameters, ...this.browseMediaQueryParameters,
mediaType: 'clips', mediaType: 'clips',
before: startTime + 1, before: latest,
after: startTime, after: earliest,
}); });
if (clipsAtSameTime) {
const index = getFirstTrueMediaChildIndex(clipsAtSameTime);
if (index != null && clipsAtSameTime.children?.length) {
return clipsAtSameTime.children[index];
}
}
} catch (e) { } catch (e) {
// Pass. This is best effort. // This is best effort.
return null;
}
if (!clips || !clips.children || !clips.children.length) {
return null;
}
for (let i = 0; i < clips.children.length; i++) {
const child = clips.children[i];
if (!isTrueMedia(child)) {
continue;
}
const clipStartTime = this._extractEventStartTimeFromBrowseMedia(child);
if (clipStartTime && clipStartTime === snapshotStartTime) {
return new View({
view: 'clip',
target: clips,
childIndex: i,
previous: this.view,
});
} }
} }
return null; return null;
} }
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _slideSelectHandler(): void {
if (!this._carousel || !this.view) {
return;
}
// Update the childIndex in the view (without re-render)
const slidesInView = this._carousel.slidesInView(true);
if (slidesInView.length) {
const childIndex = this._slideToChild[slidesInView[0]];
if (childIndex !== undefined) {
// Update the currently live view in place.
this.view.childIndex = childIndex;
this.requestUpdate();
}
}
}
/**
* Handle a next/previous control interaction.
* @param direction The direction requested, previous or next.
*/
protected _nextPreviousHandler(direction: 'previous' | 'next'): void {
if (direction == 'previous') {
this._carousel?.scrollPrev();
} else if (direction == 'next') {
this._carousel?.scrollNext();
}
}
/**
* Lazily load media in the carousel.
* @param eventName The Embla event name that triggered this load.
* // TODO delete eventName above?
*/
protected _lazyLoadMediaHandler(): void {
if (!this.lazyLoad || !this._carousel) {
return;
}
const slides = this._carousel.slideNodes();
const slidesInView = this._carousel.slidesInView(true);
const slidesToLoad = new Set<number>();
// Lazily load the selected slide and the one on each side of it to improve
// the user navigation experience.
for (let i = 0; i < slidesInView.length; i++) {
const index = slidesInView[i];
if (index > 0) {
slidesToLoad.add(index - 1);
}
slidesToLoad.add(index);
if (index < slides.length - 1) {
slidesToLoad.add(index + 1);
}
}
slidesToLoad.forEach((index) => {
const slide = slides[index];
// Snapshots.
const img = slide.querySelector('img');
// Frigate >= 0.9.0+ clips.
const hls_player = slide.querySelector(
'frigate-card-ha-hls-player',
) as HTMLElement & { url: string };
// Frigate < 0.9.0 clips. frigate-card-ha-hls-player will also have a
// video source element, so search for that first.
const video_source = slide.querySelector('video source') as HTMLElement & {
src: string;
};
if (img) {
img.src = img.getAttribute('data-src') || img.src;
} else if (hls_player) {
hls_player.url = hls_player.getAttribute('data-url') || hls_player.url;
} else if (video_source) {
video_source.src = video_source.getAttribute('data-src') || video_source.src;
}
});
}
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
return html`${until(this._render(), renderProgressIndicator())}`; if (
!this.view ||
!this.view.target ||
!this.view.target.children ||
!this.view.target.children.length ||
this.view.childIndex === undefined ||
!this.resolvedMediaCache
) {
return html``;
} }
protected async _render(): Promise<TemplateResult | void> { const slides: TemplateResult[] = [];
let autoplay = true; this._slideToChild = {};
let parent: BrowseMediaSource | null = null; for (let i = 0; i < this.view.target.children?.length; ++i) {
let childIndex: number | null = null; const slide = this._renderMediaItem(this.view.target.children[i]);
let mediaToRender: BrowseMediaSource | null = null; if (slide) {
this._slideToChild[slides.length] = i;
if (this.view.target) { slides.push(slide);
parent = this.view.target;
childIndex = this.view.childIndex ?? null;
mediaToRender = this.view.media ?? null;
} else {
try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message);
} }
childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent(
this,
this.view.is('clip')
? localize('common.no_clip')
: localize('common.no_snapshot'),
this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
}
mediaToRender = parent.children[childIndex];
// In this block, no clip has been manually selected, so this is loading
// the most recent clip on card load. In this mode, autoplay of the clip
// may be disabled by configuration. If does not make sense to disable
// autoplay when the user has explicitly picked an event to play in the
// gallery.
autoplay = this.autoplayClip;
}
const resolvedMedia = await this._resolveMedia(mediaToRender);
if (!mediaToRender || !resolvedMedia) {
// Home Assistant could not resolve media item.
return dispatchErrorMessageEvent(this, localize('error.could_not_resolve'));
} }
const neighbors = this._getMediaNeighbors(parent, childIndex); const neighbors = this._getMediaNeighbors();
return html` <div> return html`<div class="container">
${neighbors?.previousIndex != null ${neighbors && neighbors.previous
? html`<frigate-card-next-previous-control ? html`<frigate-card-next-previous-control
.control=${'previous'} .direction=${'previous'}
.controlStyle=${this.nextPreviousControlStyle} .controlStyle=${this.nextPreviousControlStyle}
.parent=${parent} .thumbnail=${neighbors.previous.thumbnail}
.childIndex=${neighbors.previousIndex} .title=${neighbors.previous.title}
.view=${this.view} @click=${() => this._nextPreviousHandler('previous')}
></frigate-card-next-previous-control>` ></frigate-card-next-previous-control>`
: ``} : ``}
<div class="embla">
<div class="embla__viewport">
<div class="embla__container">${slides}</div>
</div>
</div>
${neighbors && neighbors.next
? html`<frigate-card-next-previous-control
.direction=${'next'}
.controlStyle=${this.nextPreviousControlStyle}
.thumbnail=${neighbors.next.thumbnail}
.title=${neighbors.next.title}
@click=${() => this._nextPreviousHandler('next')}
></frigate-card-next-previous-control>`
: ``}
</div>`;
}
/**
* Render a given media item.
* @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered.
*/
protected _renderMediaItem(mediaToRender: BrowseMediaSource): TemplateResult | void {
// media that can be expanded (folders) cannot be resolved to a single media
// item, skip them.
if (!this.view || !isTrueMedia(mediaToRender)) {
return;
}
const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id);
if (!resolvedMedia) {
return;
}
return html`
<div class="embla__slide">
${this.view.is('clip') ${this.view.is('clip')
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
? html`<frigate-card-ha-hls-player ? html`<frigate-card-ha-hls-player
.hass=${this.hass} .hass=${this.hass}
.url=${resolvedMedia.url} url=${ifDefined(this.lazyLoad ? undefined : resolvedMedia.url)}
data-url=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)}
title="${mediaToRender.title}" title="${mediaToRender.title}"
muted muted
controls controls
playsinline playsinline
allow-exoplayer allow-exoplayer
?autoplay="${autoplay}" ?autoplay="${this.autoplayClip}"
> >
</frigate-card-ha-hls-player>` </frigate-card-ha-hls-player>`
: html`<video : html`<video
@@ -230,43 +579,41 @@ export class FrigateCardViewer extends LitElement {
muted muted
controls controls
playsinline playsinline
?autoplay="${autoplay}" ?autoplay="${this.autoplayClip}"
@loadedmetadata=${(e) => dispatchMediaLoadEvent(this, e)}a @loadedmetadata="${(e) => dispatchMediaLoadEvent(this, e)}"
@play=${() => dispatchPlayEvent(this)} @play=${() => dispatchPlayEvent(this)}
@pause=${() => dispatchPauseEvent(this)} @pause=${() => dispatchPauseEvent(this)}
> >
<source src="${resolvedMedia.url}" type="${resolvedMedia.mime_type}" /> <source
src=${ifDefined(this.lazyLoad ? undefined : resolvedMedia.url)}
data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)}
type="${resolvedMedia.mime_type}"
/>
</video>` </video>`
: html`<img : html`<img
src=${resolvedMedia.url} src=${ifDefined(this.lazyLoad ? IMG_TRANSPARENT_1x1 : resolvedMedia.url)}
data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)}
title="${mediaToRender.title}" title="${mediaToRender.title}"
@click=${() => { @click=${() => {
// Get clips potentially related to this snapshot. if (this._carousel?.clickAllowed()) {
this._findRelatedClips(mediaToRender).then((relatedClip) => { this._findRelatedClipView(mediaToRender).then((view) => {
if (relatedClip) { if (view) {
new View({ view.dispatchChangeEvent(this);
view: 'clip',
target: relatedClip,
}).dispatchChangeEvent(this);
} }
}); });
}
}} }}
@load=${(e) => { @load=${(e) => {
dispatchMediaLoadEvent(this, e); dispatchMediaLoadEvent(this, e);
}} }}
/>`} />`}
${neighbors?.nextIndex != null </div>
? html`<frigate-card-next-previous-control `;
.control=${'next'}
.controlStyle=${this.nextPreviousControlStyle}
.parent=${parent}
.childIndex=${neighbors.nextIndex}
.view=${this.view}
></frigate-card-next-previous-control>`
: ``}
</div>`;
} }
/**
* Get element styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(viewerStyle); return unsafeCSS(viewerStyle);
} }
+7
View File
@@ -263,6 +263,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
@change=${this._valueChanged} @change=${this._valueChanged}
></ha-switch> ></ha-switch>
</ha-formfield> </ha-formfield>
<ha-formfield .label=${localize('editor.lazy_load')}>
<ha-switch
.checked=${this._config?.event_viewer?.lazy_load !== false}
.configValue=${'event_viewer.lazy_load'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
</div>` </div>`
: ''} : ''}
<div class="option" @click=${this._toggleOption} .option=${'appearance'}> <div class="option" @click=${this._toggleOption} .option=${'appearance'}>
+2 -1
View File
@@ -34,7 +34,8 @@
"label": "Frigate label/object filter (Optional)", "label": "Frigate label/object filter (Optional)",
"live_provider": "Live view provider (Optional)", "live_provider": "Live view provider (Optional)",
"live_preload": "Preload live view", "live_preload": "Preload live view",
"image": "Static image URL for image view (Optional)" "image": "Static image URL for image view (Optional)",
"lazy_load": "Lazily load event media"
}, },
"menu": { "menu": {
"frigate": "Frigate Menu / Default View", "frigate": "Frigate Menu / Default View",
+66
View File
@@ -0,0 +1,66 @@
import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from './common.js';
import {
BrowseMediaSource,
ExtendedHomeAssistant,
ResolvedMedia,
resolvedMediaSchema,
} from './types.js';
import QuickLRU from 'quick-lru';
// It's important the cache size be at least as large as the largest likely
// media query or media items will from a given query will be evicted for other
// items in the same query (which would result in only partial results being
// returned to the user).
// Note: Each entry is about 400 bytes.
const RESOLVED_MEDIA_CACHE_SIZE = 1000;
export class ResolvedMediaCache {
protected _cache: QuickLRU<string, ResolvedMedia>;
constructor() {
this._cache = new QuickLRU({maxSize: RESOLVED_MEDIA_CACHE_SIZE});
}
public has(id: string): boolean {
return this._cache.has(id);
}
public get(id: string): ResolvedMedia | undefined {
return this._cache.get(id);
}
public set(id: string, resolvedMedia: ResolvedMedia): void {
this._cache.set(id, resolvedMedia);
}
}
export class ResolvedMediaUtil {
static async resolveMedia(
hass: HomeAssistant & ExtendedHomeAssistant,
mediaSource?: BrowseMediaSource,
cache?: ResolvedMediaCache,
): Promise<ResolvedMedia | null> {
if (!mediaSource) {
return null;
}
const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
if (cachedValue) {
return cachedValue;
}
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaSource.media_content_id,
};
const resolvedMedia = await homeAssistantWSRequest(
hass,
resolvedMediaSchema,
request,
);
if (cache && resolvedMedia) {
cache.set(mediaSource.media_content_id, resolvedMedia);
}
return resolvedMedia;
}
}
+38 -1
View File
@@ -6,7 +6,44 @@ img,video {
height: 100%; height: 100%;
display: block; display: block;
} }
div {
div.container {
// Keep the controls positioned relative to the video. // Keep the controls positioned relative to the video.
position: relative; position: relative;
} }
.embla {
position: relative;
margin-left: auto;
margin-right: auto;
}
.embla__viewport {
overflow: hidden;
width: 100%;
}
.embla__container {
display: flex;
user-select: none;
-webkit-touch-callout: none;
-khtml-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.embla__viewport.is-draggable {
cursor: move;
cursor: grab;
}
.embla__viewport.is-dragging {
cursor: grabbing;
}
.embla__slide {
position: relative;
min-width: 100%;
max-width: 100%;
margin-right: 10px;
overflow: hidden;
}
+3
View File
@@ -283,6 +283,9 @@ export const frigateCardConfigSchema = z.object({
label: z.string().optional(), label: z.string().optional(),
zone: z.string().optional(), zone: z.string().optional(),
autoplay_clip: z.boolean().default(false), autoplay_clip: z.boolean().default(false),
event_viewer: z.object({
lazy_load: z.boolean().default(true),
}).optional(),
menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'), menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'),
menu_buttons: z menu_buttons: z
.object({ .object({
+27
View File
@@ -24,14 +24,37 @@ export class View {
return this.view == name; return this.view == name;
} }
/**
* Determine if a view is a gallery.
*/
public isGalleryView(): boolean { public isGalleryView(): boolean {
return this.view == 'clips' || this.view == 'snapshots'; return this.view == 'clips' || this.view == 'snapshots';
} }
/**
* Determine if a view is of a piece of media (i.e. not the gallery).
*/
public isMediaView(): boolean { public isMediaView(): boolean {
return !this.isGalleryView(); return !this.isGalleryView();
} }
/**
* Determine if a view is related to a clip or clips.
*/
public isClipRelatedView(): boolean {
return ['clip', 'clips'].includes(this.view);
}
/**
* Determine if a view is related to a snapshot or snapshots.
*/
public isSnapshotRelatedView(): boolean {
return ['snapshot', 'snapshots'].includes(this.view);
}
/**
* Get the media item that should be played.
**/
get media(): BrowseMediaSource | undefined { get media(): BrowseMediaSource | undefined {
if (this.target) { if (this.target) {
if (this.target.children && this.childIndex !== undefined) { if (this.target.children && this.childIndex !== undefined) {
@@ -42,6 +65,10 @@ export class View {
return undefined; return undefined;
} }
/**
* Dispatch an event to request a view change.
* @param node The element dispatching the event.
*/
public dispatchChangeEvent(node: HTMLElement): void { public dispatchChangeEvent(node: HTMLElement): void {
node.dispatchEvent( node.dispatchEvent(
new CustomEvent<View>('frigate-card:change-view', { new CustomEvent<View>('frigate-card:change-view', {