diff --git a/package.json b/package.json index aff0fe03..10d675a6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@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", "screenfull": "^5.1.0", 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/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 ce75298f..1c91731e 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,7 +1,15 @@ -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 dayjs from 'dayjs'; import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; @@ -13,6 +21,7 @@ import type { ExtendedHomeAssistant, NextPreviousControlStyle, } from '../types.js'; +import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { localize } from '../localize/localize.js'; import { browseMediaQuery, @@ -22,17 +31,15 @@ import { dispatchPauseEvent, dispatchPlayEvent, getFirstTrueMediaChildIndex, + 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'; -import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; // Load dayjs plugin(s). dayjs.extend(dayjs_custom_parse_format); @@ -61,6 +68,33 @@ export class FrigateCardViewer extends LitElement { 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``; @@ -90,7 +124,7 @@ export class FrigateCardViewer extends LitElement { 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 @@ -100,31 +134,29 @@ export class FrigateCardViewer extends LitElement { autoplay = this.autoplayClip ?? true; } - const resolvedMedia = await ResolvedMediaUtil.resolveMedia( - this.hass, view.media, this.resolvedMediaCache); - if (!resolvedMedia) { - // Home Assistant could not resolve media item. + if (view.target && !(await this._resolveAllMediaForTarget(view.target))) { return dispatchErrorMessageEvent(this, localize('error.could_not_resolve')); } - return html` - - `; + 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 }) @@ -145,6 +177,58 @@ export class FrigateCardViewerCore extends LitElement { @property({ attribute: false }) protected browseMediaQueryParameters?: BrowseMediaQueryParameters; + // 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, + }); + this._carousel.on('select', this._slideSelectHandler.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 { @@ -162,12 +246,18 @@ export class FrigateCardViewerCore extends LitElement { return null; } - // Get the previous and next real media items, given the index - protected _getMediaNeighbors() : BrowseMediaNeighbors | null { - if (!this.view || - !this.view.target || - !this.view.target.children || - this.view.childIndex === undefined) { + /** + * 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; } @@ -175,7 +265,7 @@ export class FrigateCardViewerCore extends LitElement { let prevIndex: number | null = null; for (let i = this.view.childIndex - 1; i >= 0; i--) { const media = this.view.target.children[i]; - if (media && !media.can_expand) { + if (media && isTrueMedia(media)) { prevIndex = i; break; } @@ -185,7 +275,7 @@ export class FrigateCardViewerCore extends LitElement { let nextIndex: number | null = null; for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { const media = this.view.target.children[i]; - if (media && !media.can_expand) { + if (media && isTrueMedia(media)) { nextIndex = i; break; } @@ -199,115 +289,250 @@ export class FrigateCardViewerCore extends LitElement { }; } - // Get a clip at the same time as a snapshot. - protected async _findRelatedClips( - snapshot: BrowseMediaSource | null, - ): Promise { - if (!snapshot || !this.hass || !this.browseMediaQueryParameters) { + /** + * 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, 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 + } + + console.info(clips); + 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; } + /** + * 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 { + console.info(`next/prev handler: ${direction}`); + if (direction == 'previous') { + this._carousel?.scrollPrev(); + } else if (direction == 'next') { + this._carousel?.scrollNext(); + } + } + + /** + * Render the element. + * @returns A template to display to the user. + */ protected render(): TemplateResult | void { - if (!this.view || !this.resolvedMediaCache) { + if ( + !this.view || + !this.view.target || + !this.view.target.children || + !this.view.target.children.length || + this.view.childIndex === undefined || + !this.resolvedMediaCache + ) { return html``; } - const mediaToRender = this.view.media; - const resolvedMedia = mediaToRender ? this.resolvedMediaCache.get( - mediaToRender.media_content_id) : undefined; - if (!mediaToRender || !resolvedMedia) { - 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?.previousIndex != null + + 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/scss/viewer.scss b/src/scss/viewer.scss index 53c96ca5..70e1c662 100644 --- a/src/scss/viewer.scss +++ b/src/scss/viewer.scss @@ -2,11 +2,47 @@ ha-hls-player { transform-style: preserve-3d; } img,video { - width: 100%; - height: 100%; display: block; + max-width: 100%; } -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