diff --git a/.prettierrc.js b/.prettierrc.cjs similarity index 100% rename from .prettierrc.js rename to .prettierrc.cjs diff --git a/README.md b/README.md index 50bbe9d1..5b1b4dc1 100644 --- a/README.md +++ b/README.md @@ -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. | | `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.| - +| `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 diff --git a/package.json b/package.json index aff0fe03..4354e004 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "@material/image-list": "^12.0.0", "custom-card-helpers": "^1.8.0", "dayjs": "^1.10.7", + "embla-carousel": "^5.0.1", "home-assistant-js-websocket": "^5.11.1", "lit": "^2.0.2", + "quick-lru": "github:sindresorhus/quick-lru", "screenfull": "^5.1.0", "zod": "^3.10.0" }, diff --git a/src/card.ts b/src/card.ts index b736ece6..8d5b34f8 100644 --- a/src/card.ts +++ b/src/card.ts @@ -51,6 +51,7 @@ import './patches/ha-camera-stream.js'; import './patches/ha-hls-player.js'; import cardStyle from './scss/card.scss'; +import { ResolvedMediaCache } from './resolved-media.js'; const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; @@ -133,6 +134,9 @@ export class FrigateCard extends LitElement { // Error/info message to render. 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) { this._hass = hass; @@ -485,13 +489,19 @@ export class FrigateCard extends LitElement { `; } - protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters | null { - if (!this._frigateCameraName) { - return null; + /** + * Get the parameters to search for media related to the current view. + * @returns A BrowseMediaQueryParameters object. + */ + protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters | undefined { + if ( + !this._frigateCameraName || + !(this._view.isClipRelatedView() || this._view.isSnapshotRelatedView()) + ) { + return undefined; } - return { - mediaType: this._view.view == 'clips' ? 'clips' : 'snapshots', + mediaType: this._view.isClipRelatedView() ? 'clips' : 'snapshots', clientId: this.config.frigate_client_id, cameraName: this._frigateCameraName, label: this.config.label, @@ -499,10 +509,16 @@ export class FrigateCard extends LitElement { }; } + /** + * Handler for media play event. + */ protected _playHandler(): void { this._mediaPlaying = true; } + /** + * Handler for media pause event. + */ protected _pauseHandler(): void { this._mediaPlaying = false; } @@ -665,8 +681,10 @@ export class FrigateCard extends LitElement { } protected _render(): TemplateResult | void { - const mediaQueryParameters = this._getBrowseMediaQueryParameters(); - if (!this._hass || !this._frigateCameraName || !mediaQueryParameters) { + if (!this._hass) { + return html``; + } + if (!this._frigateCameraName) { this._setMessageAndUpdate( { message: localize('error.no_frigate_camera_name'), @@ -675,6 +693,7 @@ export class FrigateCard extends LitElement { true, ); } + const mediaQueryParameters = this._getBrowseMediaQueryParameters(); const pictureElementsClasses = { 'picture-elements': true, @@ -724,6 +743,8 @@ export class FrigateCard extends LitElement { .browseMediaQueryParameters=${mediaQueryParameters} .nextPreviousControlStyle=${this.config.controls?.nextprev ?? 'thumbnails'} .autoplayClip=${this.config.autoplay_clip} + .resolvedMediaCache=${this._resolvedMediaCache} + .lazyLoad=${this.config.event_viewer?.lazy_load ?? true} class="${classMap(viewerClasses)}" @frigate-card:change-view=${this._changeViewHandler} @frigate-card:media-load=${this._mediaLoadHandler} diff --git a/src/common.ts b/src/common.ts index 720a984e..5563cffd 100644 --- a/src/common.ts +++ b/src/common.ts @@ -43,6 +43,10 @@ export async function homeAssistantWSRequest( 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 // clip/snapshot, not a folder). export function getFirstTrueMediaChildIndex( @@ -52,7 +56,7 @@ export function getFirstTrueMediaChildIndex( return null; } for (let i = 0; i < media.children.length; i++) { - if (!media.children[i].can_expand) { + if (isTrueMedia(media.children[i])) { return i; } } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 72258965..bc78b7ae 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -36,7 +36,7 @@ export class FrigateCardGallery extends LitElement { protected view!: View; @property({ attribute: false }) - protected browseMediaQueryParameters!: BrowseMediaQueryParameters; + protected browseMediaQueryParameters?: BrowseMediaQueryParameters; protected _resizeObserver: ResizeObserver; @@ -72,6 +72,10 @@ export class FrigateCardGallery extends LitElement { } protected async _render(): Promise { + if (!this.browseMediaQueryParameters) { + return html``; + } + let parent: BrowseMediaSource | null; try { if (this.view.target) { diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index c8cd6ad5..9587a5d8 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -2,76 +2,54 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { BrowseMediaSource, NextPreviousControlStyle } from '../types.js'; - -import { View } from '../view.js'; +import { NextPreviousControlStyle } from '../types.js'; import controlStyle from '../scss/next-previous-control.scss'; @customElement('frigate-card-next-previous-control') export class FrigateCardMessage extends LitElement { @property({ attribute: false }) - protected control!: "next" | "previous"; + protected direction?: 'next' | 'previous'; @property({ attribute: false }) - protected controlStyle!: NextPreviousControlStyle; - - @property({ attribute: false }) - protected parent!: BrowseMediaSource; + protected controlStyle?: NextPreviousControlStyle; @property({ attribute: false }) - protected childIndex!: number; + protected thumbnail?: string; - @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) { + protected render(): TemplateResult { + if (!this.controlStyle || this.controlStyle == 'none') { return html``; } const classes = { controls: true, - previous: this.control == "previous", - next: this.control == "next", - thumbnails: this.controlStyle == "thumbnails", - chevrons: this.controlStyle == "chevrons", - button: this.controlStyle == "chevrons", + previous: this.direction == 'previous', + next: this.direction == 'next', + thumbnails: this.controlStyle == 'thumbnails', + chevrons: this.controlStyle == 'chevrons', + button: this.controlStyle == 'chevrons', }; - if (this.controlStyle == "chevrons") { + if (this.controlStyle == 'chevrons') { return html` `; } - if (!target.thumbnail) { + if (!this.thumbnail) { return html``; } return html``; } static get styles(): CSSResultGroup { return unsafeCSS(controlStyle); } -} \ No newline at end of file +} diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 0cceb894..73d855e3 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -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 { 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_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; -import { resolvedMediaSchema } from '../types.js'; import type { BrowseMediaNeighbors, BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant, NextPreviousControlStyle, - ResolvedMedia, } from '../types.js'; +import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { localize } from '../localize/localize.js'; import { browseMediaQuery, @@ -24,51 +32,224 @@ import { dispatchPauseEvent, dispatchPlayEvent, getFirstTrueMediaChildIndex, - homeAssistantWSRequest, + isTrueMedia, } from '../common.js'; import { View } from '../view.js'; -import { - renderProgressIndicator, -} from '../components/message.js'; +import { renderProgressIndicator } from '../components/message.js'; import './next-prev-control.js'; import viewerStyle from '../scss/viewer.scss'; +const IMG_TRANSPARENT_1x1 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + // Load dayjs plugin(s). dayjs.extend(dayjs_custom_parse_format); @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @property({ attribute: false }) - protected hass!: HomeAssistant & ExtendedHomeAssistant; + protected hass?: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected view!: View; + protected view?: View; @property({ attribute: false }) - protected browseMediaQueryParameters!: BrowseMediaQueryParameters; + protected browseMediaQueryParameters?: BrowseMediaQueryParameters; @property({ attribute: false }) - protected nextPreviousControlStyle!: NextPreviousControlStyle; + protected nextPreviousControlStyle?: NextPreviousControlStyle; @property({ attribute: false }) - protected autoplayClip!: boolean; + protected autoplayClip?: boolean; - protected async _resolveMedia( - mediaSource: BrowseMediaSource | null, - ): Promise { - if (!mediaSource) { - return null; - } - const request = { - type: 'media_source/resolve_media', - media_content_id: mediaSource.media_content_id, - }; - return homeAssistantWSRequest(this.hass, resolvedMediaSchema, request); + @property({ attribute: false }) + protected resolvedMediaCache?: ResolvedMediaCache; + + @property({ attribute: false }) + protected lazyLoad?: boolean; + + protected render(): TemplateResult | void { + return html`${until(this._render(), renderProgressIndicator())}`; } + /** + * 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 { + 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 { + 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` + `; + } + + /** + * 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 = {}; + + /** + * 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( browseMedia: BrowseMediaSource, ): number | null { @@ -86,20 +267,26 @@ export class FrigateCardViewer extends LitElement { return null; } - // Get the previous and next real media items, given the index - protected _getMediaNeighbors( - parent: BrowseMediaSource, - index: number | null, - ): BrowseMediaNeighbors | null { - if (index == null || !parent.children) { + /** + * Get the previous and next true media items from the current view. + * @returns A BrowseMediaNeighbors with indices and objects of true media + * neighbors. + */ + protected _getMediaNeighbors(): BrowseMediaNeighbors | null { + if ( + !this.view || + !this.view.target || + !this.view.target.children || + this.view.childIndex === undefined + ) { return null; } // Work backwards from the index to get the previous real media. let prevIndex: number | null = null; - for (let i = index - 1; i >= 0; i--) { - const media = parent.children[i]; - if (media && !media.can_expand) { + for (let i = this.view.childIndex - 1; i >= 0; i--) { + const media = this.view.target.children[i]; + if (media && isTrueMedia(media)) { prevIndex = i; break; } @@ -107,9 +294,9 @@ export class FrigateCardViewer extends LitElement { // Work forwards from the index to get the next real media. let nextIndex: number | null = null; - for (let i = index + 1; i < parent.children.length; i++) { - const media = parent.children[i]; - if (media && !media.can_expand) { + for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { + const media = this.view.target.children[i]; + if (media && isTrueMedia(media)) { nextIndex = i; break; } @@ -117,156 +304,316 @@ export class FrigateCardViewer extends LitElement { return { previousIndex: prevIndex, - previous: prevIndex != null ? parent.children[prevIndex] : null, + previous: prevIndex != null ? this.view.target.children[prevIndex] : null, 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( - snapshot: BrowseMediaSource | null, - ): Promise { - if (!snapshot) { + /** + * Get a clip view that matches a given snapshot. Includes clips within the + * same range as the current view. + * @param snapshot The snapshot to find a matching clip for. + * @returns The view that would show the matching clip. + */ + protected async _findRelatedClipView( + snapshot: BrowseMediaSource, + ): Promise { + if ( + !this.hass || + !this.view || + !this.view.target || + !this.view.target.children || + !this.view.target.children.length || + !this.browseMediaQueryParameters + ) { return null; } - const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot); - if (startTime) { - try { - // Fetch clips within the same second (same camera/zone/label, etc). - const clipsAtSameTime = await browseMediaQuery(this.hass, { - ...this.browseMediaQueryParameters, - mediaType: 'clips', - before: startTime + 1, - after: startTime, + const snapshotStartTime = this._extractEventStartTimeFromBrowseMedia(snapshot); + 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 { + 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; } - 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 { - let autoplay = true; - - let parent: BrowseMediaSource | null = null; - let childIndex: number | null = null; - let mediaToRender: BrowseMediaSource | null = null; - - if (this.view.target) { - 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; + /** + * 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(); } - 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(); + + // 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`
- ${neighbors?.previousIndex != null + // 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 { + 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`
+ ${neighbors && neighbors.previous ? html` this._nextPreviousHandler('previous')} >` : ``} - ${this.view.is('clip') - ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' - ? html` - ` - : html`` - : html` { - // 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 +
+
+
${slides}
+
+
+ ${neighbors && neighbors.next ? html` this._nextPreviousHandler('next')} >` : ``}
`; } + /** + * 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` +
+ ${this.view.is('clip') + ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' + ? html` + ` + : html`` + : html` { + if (this._carousel?.clickAllowed()) { + this._findRelatedClipView(mediaToRender).then((view) => { + if (view) { + view.dispatchChangeEvent(this); + } + }); + } + }} + @load=${(e) => { + dispatchMediaLoadEvent(this, e); + }} + />`} +
+ `; + } + + /** + * Get element styles. + */ static get styles(): CSSResultGroup { return unsafeCSS(viewerStyle); } diff --git a/src/editor.ts b/src/editor.ts index c4e45fe6..251adfe0 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -263,6 +263,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor @change=${this._valueChanged} > + + +
` : ''}
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 7e988568..7152651e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -34,7 +34,8 @@ "label": "Frigate label/object filter (Optional)", "live_provider": "Live view provider (Optional)", "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": { "frigate": "Frigate Menu / Default View", diff --git a/src/resolved-media.ts b/src/resolved-media.ts new file mode 100644 index 00000000..4268719e --- /dev/null +++ b/src/resolved-media.ts @@ -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; + + 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 { + 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; + } +} diff --git a/src/scss/viewer.scss b/src/scss/viewer.scss index 53c96ca5..a0596030 100644 --- a/src/scss/viewer.scss +++ b/src/scss/viewer.scss @@ -6,7 +6,44 @@ img,video { height: 100%; display: block; } -div { + +div.container { // Keep the controls positioned relative to the video. 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; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index b07d1028..f074cc8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -283,6 +283,9 @@ export const frigateCardConfigSchema = z.object({ label: z.string().optional(), zone: z.string().optional(), 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_buttons: z .object({ diff --git a/src/view.ts b/src/view.ts index 5408f748..0524631c 100644 --- a/src/view.ts +++ b/src/view.ts @@ -24,14 +24,37 @@ export class View { return this.view == name; } + /** + * Determine if a view is a gallery. + */ public isGalleryView(): boolean { 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 { 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 { if (this.target) { if (this.target.children && this.childIndex !== undefined) { @@ -42,6 +65,10 @@ export class View { return undefined; } + /** + * Dispatch an event to request a view change. + * @param node The element dispatching the event. + */ public dispatchChangeEvent(node: HTMLElement): void { node.dispatchEvent( new CustomEvent('frigate-card:change-view', {