Merge pull request #152 from dermotduffy/swipe2
Re-implement the media viewer as a swipe carousel
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -2,76 +2,54 @@ 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 })
|
|
||||||
protected parent!: BrowseMediaSource;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected childIndex!: number;
|
protected thumbnail?: string;
|
||||||
|
|
||||||
@property({ attribute: false })
|
protected render(): TemplateResult {
|
||||||
protected view!: View;
|
if (!this.controlStyle || this.controlStyle == 'none') {
|
||||||
|
|
||||||
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}
|
|
||||||
/>`;
|
/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(controlStyle);
|
return unsafeCSS(controlStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+503
-156
@@ -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,156 +304,316 @@ 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) {
|
||||||
try {
|
return null;
|
||||||
// Fetch clips within the same second (same camera/zone/label, etc).
|
}
|
||||||
const clipsAtSameTime = await browseMediaQuery(this.hass, {
|
|
||||||
...this.browseMediaQueryParameters,
|
// Heuristic: At this point, the user has a particular snapshot that they
|
||||||
mediaType: 'clips',
|
// are interested in and want to see a related clip, yet the viewer code
|
||||||
before: startTime + 1,
|
// does not know the exact search criteria that led to that snapshot (e.g.
|
||||||
after: startTime,
|
// 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 {
|
||||||
|
clips = await browseMediaQuery(this.hass, {
|
||||||
|
...this.browseMediaQueryParameters,
|
||||||
|
mediaType: 'clips',
|
||||||
|
before: latest,
|
||||||
|
after: earliest,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 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,
|
||||||
});
|
});
|
||||||
if (clipsAtSameTime) {
|
|
||||||
const index = getFirstTrueMediaChildIndex(clipsAtSameTime);
|
|
||||||
if (index != null && clipsAtSameTime.children?.length) {
|
|
||||||
return clipsAtSameTime.children[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Pass. This is best effort.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
/**
|
||||||
return html`${until(this._render(), renderProgressIndicator())}`;
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _render(): Promise<TemplateResult | void> {
|
/**
|
||||||
let autoplay = true;
|
* Handle a next/previous control interaction.
|
||||||
|
* @param direction The direction requested, previous or next.
|
||||||
let parent: BrowseMediaSource | null = null;
|
*/
|
||||||
let childIndex: number | null = null;
|
protected _nextPreviousHandler(direction: 'previous' | 'next'): void {
|
||||||
let mediaToRender: BrowseMediaSource | null = null;
|
if (direction == 'previous') {
|
||||||
|
this._carousel?.scrollPrev();
|
||||||
if (this.view.target) {
|
} else if (direction == 'next') {
|
||||||
parent = this.view.target;
|
this._carousel?.scrollNext();
|
||||||
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'));
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const neighbors = this._getMediaNeighbors(parent, childIndex);
|
slidesToLoad.forEach((index) => {
|
||||||
|
const slide = slides[index];
|
||||||
|
|
||||||
return html` <div>
|
// Snapshots.
|
||||||
${neighbors?.previousIndex != null
|
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 {
|
||||||
|
if (
|
||||||
|
!this.view ||
|
||||||
|
!this.view.target ||
|
||||||
|
!this.view.target.children ||
|
||||||
|
!this.view.target.children.length ||
|
||||||
|
this.view.childIndex === undefined ||
|
||||||
|
!this.resolvedMediaCache
|
||||||
|
) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slides: TemplateResult[] = [];
|
||||||
|
this._slideToChild = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < this.view.target.children?.length; ++i) {
|
||||||
|
const slide = this._renderMediaItem(this.view.target.children[i]);
|
||||||
|
if (slide) {
|
||||||
|
this._slideToChild[slides.length] = i;
|
||||||
|
slides.push(slide);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const neighbors = this._getMediaNeighbors();
|
||||||
|
|
||||||
|
return html`<div class="container">
|
||||||
|
${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>`
|
||||||
: ``}
|
: ``}
|
||||||
${this.view.is('clip')
|
<div class="embla">
|
||||||
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
|
<div class="embla__viewport">
|
||||||
? html`<frigate-card-ha-hls-player
|
<div class="embla__container">${slides}</div>
|
||||||
.hass=${this.hass}
|
</div>
|
||||||
.url=${resolvedMedia.url}
|
</div>
|
||||||
title="${mediaToRender.title}"
|
${neighbors && neighbors.next
|
||||||
muted
|
|
||||||
controls
|
|
||||||
playsinline
|
|
||||||
allow-exoplayer
|
|
||||||
?autoplay="${autoplay}"
|
|
||||||
>
|
|
||||||
</frigate-card-ha-hls-player>`
|
|
||||||
: html`<video
|
|
||||||
title="${mediaToRender.title}"
|
|
||||||
muted
|
|
||||||
controls
|
|
||||||
playsinline
|
|
||||||
?autoplay="${autoplay}"
|
|
||||||
@loadedmetadata=${(e) => dispatchMediaLoadEvent(this, e)}a
|
|
||||||
@play=${() => dispatchPlayEvent(this)}
|
|
||||||
@pause=${() => dispatchPauseEvent(this)}
|
|
||||||
>
|
|
||||||
<source src="${resolvedMedia.url}" type="${resolvedMedia.mime_type}" />
|
|
||||||
</video>`
|
|
||||||
: html`<img
|
|
||||||
src=${resolvedMedia.url}
|
|
||||||
title="${mediaToRender.title}"
|
|
||||||
@click=${() => {
|
|
||||||
// Get clips potentially related to this snapshot.
|
|
||||||
this._findRelatedClips(mediaToRender).then((relatedClip) => {
|
|
||||||
if (relatedClip) {
|
|
||||||
new View({
|
|
||||||
view: 'clip',
|
|
||||||
target: relatedClip,
|
|
||||||
}).dispatchChangeEvent(this);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
@load=${(e) => {
|
|
||||||
dispatchMediaLoadEvent(this, e);
|
|
||||||
}}
|
|
||||||
/>`}
|
|
||||||
${neighbors?.nextIndex != null
|
|
||||||
? html`<frigate-card-next-previous-control
|
? html`<frigate-card-next-previous-control
|
||||||
.control=${'next'}
|
.direction=${'next'}
|
||||||
.controlStyle=${this.nextPreviousControlStyle}
|
.controlStyle=${this.nextPreviousControlStyle}
|
||||||
.parent=${parent}
|
.thumbnail=${neighbors.next.thumbnail}
|
||||||
.childIndex=${neighbors.nextIndex}
|
.title=${neighbors.next.title}
|
||||||
.view=${this.view}
|
@click=${() => this._nextPreviousHandler('next')}
|
||||||
></frigate-card-next-previous-control>`
|
></frigate-card-next-previous-control>`
|
||||||
: ``}
|
: ``}
|
||||||
</div>`;
|
</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')
|
||||||
|
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
|
||||||
|
? html`<frigate-card-ha-hls-player
|
||||||
|
.hass=${this.hass}
|
||||||
|
url=${ifDefined(this.lazyLoad ? undefined : resolvedMedia.url)}
|
||||||
|
data-url=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)}
|
||||||
|
title="${mediaToRender.title}"
|
||||||
|
muted
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
allow-exoplayer
|
||||||
|
?autoplay="${this.autoplayClip}"
|
||||||
|
>
|
||||||
|
</frigate-card-ha-hls-player>`
|
||||||
|
: html`<video
|
||||||
|
title="${mediaToRender.title}"
|
||||||
|
muted
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
?autoplay="${this.autoplayClip}"
|
||||||
|
@loadedmetadata="${(e) => dispatchMediaLoadEvent(this, e)}"
|
||||||
|
@play=${() => dispatchPlayEvent(this)}
|
||||||
|
@pause=${() => dispatchPauseEvent(this)}
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src=${ifDefined(this.lazyLoad ? undefined : resolvedMedia.url)}
|
||||||
|
data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)}
|
||||||
|
type="${resolvedMedia.mime_type}"
|
||||||
|
/>
|
||||||
|
</video>`
|
||||||
|
: html`<img
|
||||||
|
src=${ifDefined(this.lazyLoad ? IMG_TRANSPARENT_1x1 : resolvedMedia.url)}
|
||||||
|
data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)}
|
||||||
|
title="${mediaToRender.title}"
|
||||||
|
@click=${() => {
|
||||||
|
if (this._carousel?.clickAllowed()) {
|
||||||
|
this._findRelatedClipView(mediaToRender).then((view) => {
|
||||||
|
if (view) {
|
||||||
|
view.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@load=${(e) => {
|
||||||
|
dispatchMediaLoadEvent(this, e);
|
||||||
|
}}
|
||||||
|
/>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get element styles.
|
||||||
|
*/
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(viewerStyle);
|
return unsafeCSS(viewerStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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', {
|
||||||
|
|||||||
Reference in New Issue
Block a user