diff --git a/src/card.ts b/src/card.ts index d2d42910..ba9fe988 100644 --- a/src/card.ts +++ b/src/card.ts @@ -43,7 +43,6 @@ import { CARD_VERSION, REPO_URL } from './const.js'; import { FrigateCardElements } from './components/elements.js'; import { FRIGATE_BUTTON_MENU_ICON, - MENU_HEIGHT, FrigateCardMenu, } from './components/menu.js'; import { View } from './view.js'; diff --git a/src/components/carousel.ts b/src/components/carousel.ts new file mode 100644 index 00000000..b7007314 --- /dev/null +++ b/src/components/carousel.ts @@ -0,0 +1,103 @@ +import { + CSSResultGroup, + LitElement, + TemplateResult, + html, + unsafeCSS, + PropertyValues, +} from 'lit'; +import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; + +import { dispatchFrigateCardEvent } from '../common'; + +import carouselStyle from '../scss/carousel.scss'; + +export interface CarouselTap { + index: number; +} +export interface CarouselSelect { + index: number; +} + +export class FrigateCardCarousel extends LitElement { + protected _options?: EmblaOptionsType; + protected _carousel?: EmblaCarouselType; + + /** + * Scroll to a particular slide. + * @param index Slide number. + */ + carouselScrollTo(index: number): void { + this._carousel?.scrollTo(index); + } + + /** + * 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._carousel) { + this.updateComplete.then(() => { + this._loadCarousel(); + }); + } + } + + /** + * Get slides to include in the render. + * @returns The slides to include in the render. + */ + protected _getSlides(): TemplateResult[] { + return []; + } + + /** + * Load the carousel with "slides". + */ + protected _loadCarousel(): void { + const carouselNode = this.renderRoot.querySelector( + '.embla__viewport', + ) as HTMLElement; + + if (carouselNode && !this._carousel) { + this._carousel = EmblaCarousel(carouselNode, this._options); + this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); + this._carousel.on('resize', () => + dispatchFrigateCardEvent(this, 'carousel:resize'), + ); + this._carousel.on('select', () => { + if (this._carousel) { + dispatchFrigateCardEvent(this, 'carousel:select', { + index: this._carousel.selectedScrollSnap(), + }); + } + }); + } + } + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + const slides = this._getSlides(); + if (!slides) { + return; + } + + return html`
+
+
${slides}
+
+
`; + } + + /** + * Get element styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(carouselStyle); + } +} diff --git a/src/components/menu.ts b/src/components/menu.ts index 29625e4e..5aacc472 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -28,7 +28,6 @@ import { import menuStyle from '../scss/menu.scss'; import { ConditionState, evaluateCondition } from '../card-condition.js'; -export const MENU_HEIGHT = 46; export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; /** diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts new file mode 100644 index 00000000..0e5bc73e --- /dev/null +++ b/src/components/thumbnail-carousel.ts @@ -0,0 +1,102 @@ +import { BrowseMediaUtil } from '../browse-media-util.js'; +import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import type { BrowseMediaSource } from '../types.js'; +import { CarouselTap, FrigateCardCarousel } from './carousel.js'; +import { actionHandler } from '../action-handler-directive.js'; +import { dispatchFrigateCardEvent } from '../common.js'; + +import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; + +@customElement('frigate-card-thumbnail-carousel') +export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { + @property({ attribute: false }) + protected target?: BrowseMediaSource; + + protected _tapSelected? = 0; + + constructor() { + super(); + this._options = { + containScroll: 'keepSnaps', + dragFree: true, + }; + } + + /** + * Scroll to a particular slide. + * @param index Slide number. + */ + carouselScrollTo(index: number): void { + if (!this._carousel) { + return; + } + + if (this._tapSelected !== undefined) { + this._carousel.slideNodes()[this._tapSelected].classList.remove("slide-selected"); + } + + super.carouselScrollTo(index); + + this._carousel.slideNodes()[index].classList.add("slide-selected"); + this._tapSelected = index; + } + + /** + * Get slides to include in the render. + * @returns The slides to include in the render. + */ + protected _getSlides(): TemplateResult[] { + if (!this.target || !this.target.children || !this.target.children.length) { + return []; + } + + const slides: TemplateResult[] = []; + for (let i = 0; i < this.target.children.length; ++i) { + const thumbnail = this._renderThumbnail(this.target.children[i], slides.length); + if (thumbnail) { + slides.push(thumbnail); + } + } + return slides; + } + + /** + * Render a given thumbnail. + * @param mediaToRender The media item to render. + * @returns A template or void if the item could not be rendered. + */ + protected _renderThumbnail( + mediaToRender: BrowseMediaSource, + slideIndex: number, + ): TemplateResult | void { + if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) { + return; + } + + return html`
{ + if (this._carousel && this._carousel.clickAllowed()) { + dispatchFrigateCardEvent(this, 'carousel:tap', { + index: slideIndex + }); + } + }} + > + +
`; + } + + /** + * Get element styles. + */ + static get styles(): CSSResultGroup { + return [super.styles, unsafeCSS(thumbnailCarouselStyle)]; + } +} diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 29023bfb..76c4caa2 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -9,6 +9,7 @@ import { import { BrowseMediaUtil } from '../browse-media-util.js'; import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit-html/directives/if-defined.js'; import { until } from 'lit/directives/until.js'; @@ -21,8 +22,11 @@ import type { MediaShowInfo, ViewerConfig, } from '../types.js'; +import { CarouselTap } from './carousel.js'; +import { FrigateCardThumbnailCarousel } from './thumbnail-carousel.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { View } from '../view.js'; +import { actionHandler } from '../action-handler-directive.js'; import { createMediaShowInfo, dispatchErrorMessageEvent, @@ -36,10 +40,10 @@ import { localize } from '../localize/localize.js'; import { renderProgressIndicator } from '../components/message.js'; import './next-prev-control.js'; +import './thumbnail-carousel.js'; import viewerStyle from '../scss/viewer.scss'; import viewerCoreStyle from '../scss/viewer-core.scss'; -import { actionHandler } from '../action-handler-directive.js'; const getEmptyImageSrc = (width: number, height: number) => `data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`; @@ -169,9 +173,10 @@ export class FrigateCardViewerCore extends LitElement { // Media carousel object. protected _carousel?: EmblaCarouselType; - protected _thumbnailCarousel?: EmblaCarouselType; protected _loadedCarousel = false; + protected _thumbnailCarouselRef: Ref = createRef(); + // Mapping of slide # to BrowseMediaSource child #. // (Folders are not media items that can be rendered). protected _slideToChild: Record = {}; @@ -229,37 +234,17 @@ export class FrigateCardViewerCore extends LitElement { this._carousel.on('select', this._lazyLoadMediaHandler.bind(this)); this._carousel.on('resize', this._lazyLoadMediaHandler.bind(this)); - const thumbCarouselNode = this.renderRoot.querySelector( - '.embla-thumbnails__viewport', - ) as HTMLElement; - if (thumbCarouselNode) { - this._thumbnailCarousel = EmblaCarousel(thumbCarouselNode, { - containScroll: 'keepSnaps', - dragFree: true, - }); - - this._carousel.on('select', this._syncThumbnailCarousel.bind(this)); - this._thumbnailCarousel.on('init', this._syncThumbnailCarousel.bind(this)); - } + this._carousel.on('select', this._syncThumbnailCarousel.bind(this)); } } protected _syncThumbnailCarousel(): void { - if (!this._carousel || !this._thumbnailCarousel) { + if (!this._carousel) { return; } - const previous = this._carousel.previousScrollSnap(); - const selected = this._carousel.selectedScrollSnap(); - - this._thumbnailCarousel - .slideNodes() - [previous].classList.remove('main-carousel-selected'); - this._thumbnailCarousel - .slideNodes() - [selected].classList.add('main-carousel-selected'); - - this._thumbnailCarousel.scrollTo(selected); + this._thumbnailCarouselRef.value?.carouselScrollTo( + this._carousel.selectedScrollSnap()); } /** @@ -500,23 +485,15 @@ export class FrigateCardViewerCore extends LitElement { } const slides: TemplateResult[] = []; - const thumbnails: TemplateResult[] = []; this._slideToChild = {}; for (let i = 0; i < this.view.target.children?.length; ++i) { const slide = this._renderMediaItem(this.view.target.children[i], slides.length); - const thumbnail = this._renderThumbnail( - this.view.target.children[i], - slides.length, - ); if (slide) { this._slideToChild[slides.length] = i; slides.push(slide); } - if (thumbnail) { - thumbnails.push(thumbnail); - } } const neighbors = this._getMediaNeighbors(); @@ -556,11 +533,14 @@ export class FrigateCardViewerCore extends LitElement { >` : ``} -
-
-
${thumbnails}
-
-
`; + ) => this._carousel?.scrollTo(ev.detail.index)} + @frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)} + > + `; } /** @@ -640,38 +620,6 @@ export class FrigateCardViewerCore extends LitElement { } } - /** - * 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 _renderThumbnail( - mediaToRender: BrowseMediaSource, - slideIndex: number, - ): TemplateResult | void { - if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) { - return; - } - return html`
- { - if (!this._carousel || !this._thumbnailCarousel) { - return; - } else if (this._thumbnailCarousel.clickAllowed()) { - this._carousel.scrollTo(slideIndex); - } - }} - /> -
`; - } - protected _renderMediaItem( mediaToRender: BrowseMediaSource, slideIndex: number, diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss new file mode 100644 index 00000000..0ecdb6d4 --- /dev/null +++ b/src/scss/carousel.scss @@ -0,0 +1,53 @@ +:host { + display: block; + height: 100%; + width: 100%; +} + +img,video { + width: 100%; + height: 100%; + display: block; +} + +.embla { + position: relative; + margin-left: auto; + margin-right: auto; +} + +.embla__container { + display: flex; + width: 100%; + height: 100%; + + user-select: none; + -webkit-touch-callout: none; + -khtml-user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.embla__viewport { + width: 100%; + height: 100%; + overflow: hidden; +} +.embla__viewport.is-draggable { + cursor: move; + cursor: grab; +} +.embla__viewport.is-dragging { + cursor: grabbing; +} + +.embla__slide { + position: relative; + height: 100%; + margin-right: 5px; + overflow: visible; +} +.embla__slide img,video { + // Letterbox media. has similar added directly in + // its element. + object-fit: contain; +} \ No newline at end of file diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss new file mode 100644 index 00000000..aca8fe51 --- /dev/null +++ b/src/scss/thumbnail-carousel.scss @@ -0,0 +1,14 @@ +.embla__slide { + flex: 0 0 var(--frigate-card-viewer-thumbnail-size); + opacity: 0.4; + transition: opacity 1s ease, transform 0.3s ease; +} +.embla__slide.slide-selected { + opacity: 1.0; +} +.embla__slide:hover { + transform: scale(1.1); +} +.embla__slide img { + border-radius: 5px; +} \ No newline at end of file diff --git a/src/scss/viewer-core.scss b/src/scss/viewer-core.scss index e4eb8176..83bc822b 100644 --- a/src/scss/viewer-core.scss +++ b/src/scss/viewer-core.scss @@ -15,7 +15,7 @@ img,video { display: block; } -.embla, .embla-thumbnails { +.embla { position: relative; margin-left: auto; margin-right: auto; @@ -27,13 +27,12 @@ img,video { flex: 1; min-height: 0; } -.embla-thumbnails { +frigate-card-thumbnail-carousel { flex: 0 0 var(--frigate-card-viewer-thumbnail-size); margin-top: 5px; - //margin-bottom: 5px; } -.embla__container, .embla-thumbnails__container { +.embla__container { display: flex; width: 100%; height: 100%; @@ -44,20 +43,20 @@ img,video { -webkit-tap-highlight-color: transparent; } -.embla__viewport, .embla-thumbnails__viewport { +.embla__viewport { width: 100%; height: 100%; overflow: hidden; } -.embla__viewport.is-draggable, .embla-thumbnails__viewport.is-draggable { +.embla__viewport.is-draggable { cursor: move; cursor: grab; } -.embla__viewport.is-dragging, .embla-thumbnails__viewport.is-dragging { +.embla__viewport.is-dragging { cursor: grabbing; } -.embla__slide, .embla-thumbnails__slide { +.embla__slide { position: relative; height: 100%; margin-right: 5px; @@ -66,16 +65,6 @@ img,video { .embla__slide { flex: 0 0 100%; } -.embla-thumbnails__slide { - flex: 0 0 var(--frigate-card-viewer-thumbnail-size); - border-radius: 5px; - opacity: 0.4; - transition: opacity 0.2s; -} -.embla-thumbnails__slide.main-carousel-selected { - opacity: 1.0; -} - .embla__slide img,video { // Letterbox media. has similar added directly in // its element.