Refactor carousel into separate components.

This commit is contained in:
Dermot Duffy
2022-07-09 22:43:43 -07:00
parent 30fec98e18
commit dedecc24c6
18 changed files with 698 additions and 517 deletions
+156 -38
View File
@@ -1,10 +1,20 @@
import EmblaCarousel, {
EmblaCarouselType,
EmblaOptionsType,
EmblaPluginType
} from 'embla-carousel';
import { CSSResultGroup, LitElement, PropertyValues, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel';
import { EmblaNodesType } from 'embla-carousel/components';
import {
CreatePluginType,
EmblaPluginsType,
LoosePluginType,
} from 'embla-carousel/components/Plugins';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import carouselStyle from '../scss/carousel.scss';
import { TransitionEffect } from '../types';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
@@ -13,28 +23,120 @@ export interface CarouselSelect {
index: number;
}
export type EmblaCarouselPlugins = CreatePluginType<
LoosePluginType,
Record<string, unknown>
>[];
@customElement('frigate-card-carousel')
export class FrigateCardCarousel extends LitElement {
@property({ attribute: true, reflect: true })
public direction: 'vertical' | 'horizontal' = 'horizontal';
@property({ attribute: false })
public carouselOptions?: EmblaOptionsType;
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: true })
public transitionEffect?: TransitionEffect;
protected _refSlot: Ref<HTMLSlotElement> = createRef();
protected _carousel?: EmblaCarouselType;
connectedCallback(): void {
super.connectedCallback();
// Guarantee a re-render if the component is reconnected. See note in
// disconnectedCallback().
this.requestUpdate();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
// Destroy the carousel when the component is disconnected, which forces the
// plugins (which may have registered event handlers) to also be destroyed.
// The carousel will automatically reconstruct if the component is re-rendered.
this._destroyCarousel();
super.disconnectedCallback();
}
/**
* Scroll to a particular slide.
* @param index Slide number.
*/
carouselScrollTo(index: number): void {
this._carousel?.scrollTo(index, this._getTransitionEffect() === 'none');
public carouselScrollTo(index: number): void {
this._carousel?.scrollTo(index, this.transitionEffect === 'none');
}
/**
* Scroll to the previous slide.
*/
public carouselScrollPrevious(): void {
this._carousel?.scrollPrev(this.transitionEffect === 'none');
}
/**
* Scroll to the next slide.
*/
public carouselScrollNext(): void {
this._carousel?.scrollNext(this.transitionEffect === 'none');
}
/**
* Get the selected slide.
* @returns The slide index or undefined if the carousel is not loaded.
*/
carouselSelected(): number | undefined {
public carouselSelected(): number | undefined {
return this._carousel?.selectedScrollSnap();
}
/**
* Get the selected node.
* @returns The slide index or undefined if the carousel is not loaded.
*/
public carouselSelectedElement(): HTMLElement | null {
const selected = this._carousel?.selectedScrollSnap();
if (selected !== undefined) {
return this._carousel?.slideNodes()[selected] ?? null;
}
return null;
}
/**
* Get the carousel.
*/
public carouselClickAllowed(): boolean {
return this._carousel?.clickAllowed() ?? true;
}
/**
* Get the carousel.
*/
public carousel(): EmblaCarouselType | null {
return this._carousel ?? null;
}
/**
* ReInit the carousel.
*/
public carouselReInit(): void {
// Safari appears to not loop the carousel unless the options are passed
// back in during re-initialization.
return this._carousel?.reInit(this.carouselOptions);
}
/**
* Get the live carousel plugins.
*/
public getCarouselPlugins(): EmblaPluginsType | null {
return this._carousel?.plugins() ?? null;
}
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
@@ -52,30 +154,6 @@ export class FrigateCardCarousel extends LitElement {
}
}
/**
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
protected _getTransitionEffect(): TransitionEffect | undefined {
return 'slide';
}
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType | undefined {
return undefined;
}
/**
* Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes.
*/
protected _getPlugins(): EmblaPluginType[] {
return [];
}
protected _destroyCarousel(): void {
if (this._carousel) {
this._carousel.destroy();
@@ -91,14 +169,21 @@ export class FrigateCardCarousel extends LitElement {
'.embla__viewport',
) as HTMLElement;
if (carouselNode) {
const nodes: EmblaNodesType = {
root: carouselNode,
// As the slides are slotted, need to explicitly pull them out and pass
// them to Embla.
slides: this._refSlot.value?.assignedElements({ flatten: true }) as HTMLElement[],
};
if (carouselNode && nodes.slides) {
this._carousel = EmblaCarousel(
carouselNode,
nodes,
{
axis: this.direction == 'horizontal' ? 'x' : 'y',
...this._getOptions(),
...this.carouselOptions,
},
this._getPlugins() ?? [],
this.carouselPlugins,
);
this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init'));
this._carousel.on('select', () => {
@@ -112,6 +197,33 @@ export class FrigateCardCarousel extends LitElement {
}
}
/**
* Called when the slotted children in the carousel change.
*/
protected _slotChanged(): void {
// Cannot just re-init, because the slide elements themselves may have
// changed, and only a carousel init can pass in new (slotted) children.
this._destroyCarousel();
this.requestUpdate();
}
protected render(): TemplateResult | void {
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
const currentSlide = this._carousel?.selectedScrollSnap() ?? 0;
const showPrevious = this.carouselOptions?.loop || currentSlide > 0;
const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
return html` <div class="embla">
${showPrevious ? html`<slot name="previous"></slot>` : ``}
<div class="embla__viewport">
<div class="embla__container">
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged}></slot>
</div>
</div>
${showNext ? html`<slot name="next"></slot>` : ``}
</div>`;
}
/**
* Get element styles.
*/
@@ -119,3 +231,9 @@ export class FrigateCardCarousel extends LitElement {
return unsafeCSS(carouselStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-carousel': FrigateCardCarousel;
}
}
+8 -2
View File
@@ -39,6 +39,12 @@ export type AutoMediaType = CreatePluginType<
AutoMediaOptionsType
>;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMedia?: AutoMediaType
}
}
/**
* An Embla plugin to take automated actions on media (e.g. pause, unmute, etc).
* @param userOptions
@@ -112,7 +118,7 @@ export function AutoMediaPlugin(
* Handle document visibility changes.
*/
function visibilityHandler(): void {
if (document.visibilityState == 'hidden') {
if (document.visibilityState === 'hidden') {
if (
options.autoPauseCondition &&
['all', 'hidden'].includes(options.autoPauseCondition)
@@ -125,7 +131,7 @@ export function AutoMediaPlugin(
) {
muteAll();
}
} else if (document.visibilityState == 'visible') {
} else if (document.visibilityState === 'visible') {
if (
options.autoPlayCondition &&
['all', 'visible'].includes(options.autoPlayCondition)
+7 -1
View File
@@ -19,7 +19,7 @@ export const defaultOptions: OptionsType = {
lazyLoadCount: 0,
};
export type LazyloadOptionsType = Partial<OptionsType>
export type LazyloadOptionsType = Partial<OptionsType>;
export type LazyloadType = CreatePluginType<
{
@@ -28,6 +28,12 @@ export type LazyloadType = CreatePluginType<
LazyloadOptionsType
>;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
lazyload?: LazyloadType;
}
}
export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
const optionsHandler = EmblaCarousel.optionsHandler();
const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions);
+93 -120
View File
@@ -15,12 +15,16 @@ import { customElement, property, state } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { until } from 'lit/directives/until.js';
import { ConditionState, getOverriddenConfig } from '../card-condition.js';
import { dispatchFrigateCardErrorEvent, renderProgressIndicator } from '../components/message.js';
import {
dispatchFrigateCardErrorEvent,
renderProgressIndicator,
} from '../components/message.js';
import { localize } from '../localize/localize.js';
import liveFrigateStyle from '../scss/live-frigate.scss';
import liveJSMPEGStyle from '../scss/live-jsmpeg.scss';
import liveWebRTCStyle from '../scss/live-webrtc.scss';
import liveStyle from '../scss/live.scss';
import liveCarouselStyle from '../scss/live-carousel.scss';
import {
CameraConfig,
ExtendedHomeAssistant,
@@ -47,10 +51,9 @@ import {
import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js';
import { FrigateCardMediaCarousel } from './media-carousel.js';
import { FrigateCardMediaCarousel, wrapMediaShowEventForCarousel } from './media-carousel.js';
import { dispatchErrorMessageEvent } from './message.js';
import './next-prev-control.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import './title-control.js';
import './surround-thumbnails';
import '../patches/ha-camera-stream';
@@ -182,7 +185,7 @@ export class FrigateCardLive extends LitElement {
}
@customElement('frigate-card-live-carousel')
export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@@ -206,40 +209,39 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
// Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
if (
this._carousel &&
(changedProperties.has('cameras') || changedProperties.has('liveConfig'))
) {
// All of these properties may fundamentally change the contents/size of
// the DOM, and the carousel should be reset when they change.
this._destroyCarousel();
}
super.updated(changedProperties);
const frigateCardMediaCarousel = this._refMediaCarousel.value;
const frigateCardCarousel = frigateCardMediaCarousel?.frigateCardCarousel();
if (changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined;
if (
this._carousel &&
frigateCardCarousel &&
oldView &&
this.view?.camera &&
this.view?.camera != oldView.camera
) {
const slide: number | undefined = this._cameraToSlide[this.view.camera];
if (slide !== undefined && slide !== this.carouselSelected()) {
this.carouselScrollTo(slide);
if (slide !== undefined && slide !== frigateCardCarousel.carouselSelected()) {
frigateCardCarousel.carouselScrollTo(slide);
}
}
}
if (changedProperties.has('preloaded')) {
const automedia = this._getAutoMediaPlugin();
if (
frigateCardMediaCarousel &&
frigateCardCarousel &&
changedProperties.has('preloaded')
) {
const automedia = frigateCardCarousel.getCarouselPlugins()?.autoMedia;
if (automedia) {
// If this has changed to preloaded (i.e. is now loaded but in the
// background) take the appropriate play/pause/mute/unmute actions.
@@ -257,8 +259,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
automedia.mute();
}
} else {
this._autoPlayHandler();
this._autoUnmuteHandler();
frigateCardMediaCarousel.autoPlay();
frigateCardMediaCarousel.autoUnmute();
}
}
}
@@ -268,8 +270,11 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
protected _getTransitionEffect(): TransitionEffect | undefined {
return this.liveConfig?.transition_effect;
protected _getTransitionEffect(): TransitionEffect {
return (
this.liveConfig?.transition_effect ??
frigateCardConfigDefaults.live.transition_effect
);
}
/**
@@ -293,7 +298,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
*/
protected _getPlugins(): EmblaPluginType[] {
return [
...super._getPlugins(),
// Only enable wheel plugin if there is more than one camera.
...(this.cameras && this.cameras.size > 1
? [
@@ -314,6 +318,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
lazyUnloadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('unload', index, slide),
}),
// TODO: AutoMediaPlugin could be moved to MediaCarousel.
AutoMediaPlugin({
playerSelector: 'frigate-card-live-provider',
...(this.liveConfig?.auto_play && {
@@ -332,30 +338,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
];
}
/**
* Play the media on the loaded slide.
*/
protected _autoPlayHandler(): void {
if (
this.liveConfig?.auto_play &&
['all', 'selected'].includes(this.liveConfig.auto_play)
) {
super._autoPlayHandler();
}
}
/**
* Unmute the media on the loaded slide.
*/
protected _autoUnmuteHandler(): void {
if (
this.liveConfig?.auto_unmute &&
['all', 'selected'].includes(this.liveConfig.auto_unmute)
) {
super._autoUnmuteHandler();
}
}
/**
* Returns the number of slides to lazily load. 0 means all slides are lazy
* loaded, 1 means that 1 slide on each side of the currently selected slide
@@ -394,15 +376,17 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _selectSlideSetViewHandler(): void {
if (!this._carousel || !this.view || !this.cameras) {
protected _setViewHandler(): void {
const selectedCameraIndex = this._refMediaCarousel.value
?.frigateCardCarousel()
?.carouselSelected();
if (selectedCameraIndex === undefined || !this.view || !this.cameras) {
return;
}
const selectedSnap = this._carousel.selectedScrollSnap();
this.view
.evolve({
camera: Array.from(this.cameras.keys())[selectedSnap],
camera: Array.from(this.cameras.keys())[selectedCameraIndex],
// Reset the target so thumbnails will be re-fetched.
target: null,
@@ -419,9 +403,13 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
protected _lazyloadOrUnloadSlide(
action: 'load' | 'unload',
_index: number,
slide: HTMLElement,
slide: Element,
): void {
const liveProvider = slide.querySelector(
if (slide instanceof HTMLSlotElement) {
slide = slide.assignedElements({flatten: true})[0];
}
const liveProvider = slide?.querySelector(
'frigate-card-live-provider',
) as FrigateCardLiveProvider;
if (liveProvider) {
@@ -451,18 +439,21 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
conditionState,
) as LiveConfig;
return html` <div class="embla__slide">
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig}
.label=${getCameraTitle(this.hass, cameraConfig)}
.liveConfig=${config}
.hass=${this.hass}
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
this._mediaShowEventHandler(slideIndex, e)}
>
</frigate-card-live-provider>
</div>`;
return html`
<div class="embla__slide">
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig}
.label=${getCameraTitle(this.hass, cameraConfig)}
.liveConfig=${config}
.hass=${this.hass}
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => {
wrapMediaShowEventForCarousel(slideIndex, e)
}}
>
</frigate-card-live-provider>
</div>
`;
}
protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] {
@@ -487,30 +478,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
return [prev, next];
}
/**
* Handle updating of the next/previous controls when the carousel is moved.
*/
protected _selectSlideNextPreviousHandler(): void {
const updateNextPreviousControl = (
control: FrigateCardNextPreviousControl,
direction: 'previous' | 'next',
): void => {
const [prev, next] = this._getCameraNeighbors();
const target = direction == 'previous' ? prev : next;
control.disabled = target == null;
control.title = getCameraTitle(this.hass, target);
control.icon = getCameraIcon(this.hass, target);
};
if (this._previousControlRef.value) {
updateNextPreviousControl(this._previousControlRef.value, 'previous');
}
if (this._nextControlRef.value) {
updateNextPreviousControl(this._nextControlRef.value, 'next');
}
}
/**
* Render the element.
* @returns A template to display to the user.
@@ -532,46 +499,58 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera));
return html`
<div class="embla">
<frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${this._getOptions()}
.carouselPlugins=${this._getPlugins()}
.autoPlayCondition=${this.liveConfig.auto_play}
.autoPauseCondition=${this.liveConfig.auto_pause}
.autoMuteCondition=${this.liveConfig.auto_mute}
.autoUnmuteCondition=${this.liveConfig.auto_unmute}
.label="${title ? `${localize('common.live')}: ${title}` : ''}"
.titlePopupConfig=${config.controls.title}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
>
<frigate-card-next-previous-control
${ref(this._previousControlRef)}
slot="previous"
.direction=${'previous'}
.controlConfig=${config.controls.next_previous}
.label=${getCameraTitle(this.hass, prev)}
.icon=${getCameraIcon(this.hass, prev)}
?disabled=${prev == null}
@click=${(ev) => {
this._nextPreviousHandler('previous');
this._refMediaCarousel.value
?.frigateCardCarousel()
?.carouselScrollPrevious();
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-next-previous-control>
<div class="embla__viewport">
<div class="embla__container">${slides}</div>
</div>
${slides}
<frigate-card-next-previous-control
${ref(this._nextControlRef)}
slot="next"
.direction=${'next'}
.controlConfig=${config.controls.next_previous}
.label=${getCameraTitle(this.hass, next)}
.icon=${getCameraIcon(this.hass, next)}
?disabled=${next == null}
@click=${(ev) => {
this._nextPreviousHandler('next');
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-next-previous-control>
</div>
<frigate-card-title-control
${ref(this._titleControlRef)}
.config=${config.controls.title}
.text="${title ? `${localize('common.live')}: ${title}` : ''}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control>
</frigate-card-media-carousel>
`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveCarouselStyle);
}
}
@customElement('frigate-card-live-provider')
@@ -747,20 +726,16 @@ export class FrigateCardLiveFrigate extends LitElement {
}
if (!this.cameraConfig?.camera_entity) {
return dispatchErrorMessageEvent(
this,
localize('error.no_live_camera'),
{ context: this.cameraConfig },
);
return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), {
context: this.cameraConfig,
});
}
const stateObj = this.hass.states[this.cameraConfig.camera_entity];
if (!stateObj || stateObj.state === 'unavailable') {
return dispatchErrorMessageEvent(
this,
localize('error.live_camera_unavailable'),
{ context: this.cameraConfig },
);
return dispatchErrorMessageEvent(this, localize('error.live_camera_unavailable'), {
context: this.cameraConfig,
});
}
return html` <frigate-card-ha-camera-stream
@@ -1143,11 +1118,9 @@ export class FrigateCardLiveJSMPEG extends LitElement {
this._jsmpegCanvasElement.className = 'media';
if (!this.cameraConfig?.frigate.camera_name) {
return dispatchErrorMessageEvent(
this,
localize('error.no_camera_name'),
{ context: this.cameraConfig },
);
return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), {
context: this.cameraConfig,
});
}
const url = await this._getURL();
+200 -128
View File
@@ -1,17 +1,32 @@
import { EmblaCarouselType } from 'embla-carousel';
import { CSSResultGroup, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { createRef, Ref } from 'lit/directives/ref.js';
// TODO: Use the auto-height plugin instead of adaptive height
import { EmblaOptionsType } from 'embla-carousel';
import { EmblaPluginsType } from 'embla-carousel/components/Plugins';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import mediaCarouselStyle from '../scss/media-carousel.scss';
import type { MediaShowInfo } from '../types.js';
import type {
AutoMuteCondition,
AutoPauseCondition,
AutoPlayCondition,
AutoUnmuteCondition,
MediaShowInfo,
NextPreviousControlConfig,
TitleControlConfig,
TransitionEffect,
} from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic';
import {
createMediaShowInfo,
dispatchExistingMediaShowInfoAsEvent,
isValidMediaShowInfo
isValidMediaShowInfo,
} from '../utils/media-info.js';
import { FrigateCardCarousel } from './carousel.js';
import { EmblaCarouselPlugins, FrigateCardCarousel } from './carousel';
import { AutoMediaType } from './embla-plugins/automedia.js';
import { LazyloadType } from './embla-plugins/lazyload';
import './next-prev-control.js';
import './carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js';
@@ -19,8 +34,90 @@ 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`;
export const IMG_EMPTY = getEmptyImageSrc(16, 9);
export interface CarouselMediaShowInfo {
slide: number;
mediaShowInfo: MediaShowInfo;
}
/**
* Dispatch a carousel media show event.
* @param target The target to send it from.
* @param carouselMediaShowInfo The CarouselMediaShowInfo.
*/
const dispatchFrigateCardCarouselMediaShow = (
target: EventTarget,
carouselMediaShowInfo: CarouselMediaShowInfo,
): void => {
dispatchFrigateCardEvent<CarouselMediaShowInfo>(
target,
'carousel:media-show',
carouselMediaShowInfo,
);
};
/**
* Turn a MediaShowEvent into a CarouselMediaShowInfo.
* @param slide The slide number.
* @param event The MediaShowEvent.
*/
export const wrapMediaShowEventForCarousel = (
slide: number,
event: CustomEvent<MediaShowInfo>,
) => {
event.stopPropagation();
dispatchFrigateCardCarouselMediaShow(event.composedPath()[0], {
slide: slide,
mediaShowInfo: event.detail,
});
};
/**
* Turn a (stock) media load event into a CarouselMediaShowInfo.
* @param slide The slide number.
* @param event The MediaShowEvent.
*/
export const wrapMediaLoadEventForCarousel = (slide: number, event: Event) => {
const mediaShowInfo = createMediaShowInfo(event);
if (mediaShowInfo) {
dispatchFrigateCardCarouselMediaShow(event.composedPath()[0], {
slide: slide,
mediaShowInfo: mediaShowInfo,
});
}
};
@customElement('frigate-card-media-carousel')
export class FrigateCardMediaCarousel extends FrigateCardCarousel {
export class FrigateCardMediaCarousel extends LitElement {
@property({ attribute: false })
public nextPreviousConfig?: NextPreviousControlConfig;
@property({ attribute: false })
public carouselOptions?: EmblaOptionsType;
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: true })
public transitionEffect?: TransitionEffect;
@property({ attribute: false })
public label?: string;
@property({ attribute: false })
public titlePopupConfig?: TitleControlConfig;
@property({ attribute: false })
public autoPlayCondition?: AutoPlayCondition;
@property({ attribute: false })
public autoUnmuteCondition?: AutoUnmuteCondition;
@property({ attribute: false })
public autoPauseCondition?: AutoPauseCondition;
@property({ attribute: false })
public autoMuteCondition?: AutoMuteCondition;
// A "map" from slide number to MediaShowInfo object.
protected _mediaShowInfo: Record<number, MediaShowInfo> = {};
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
@@ -28,12 +125,19 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
protected _titleTimerID: number | null = null;
protected _boundAutoPlayHandler = this.autoPlay.bind(this);
protected _boundAutoPauseHandler = this.autoPause.bind(this);
protected _boundAutoMuteHandler = this.autoMute.bind(this);
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
// This carousel may be resized by Lovelace resizes, window resizes,
// fullscreen, etc. Always call the adaptive height handler when the size
// changes.
protected _resizeObserver: ResizeObserver;
protected _intersectionObserver: IntersectionObserver;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this));
@@ -42,36 +146,61 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
);
}
/**
* Get the underlying carousel.
*/
public frigateCardCarousel(): FrigateCardCarousel | null {
return this._refCarousel.value ?? null;
}
/**
* Get the AutoMedia plugin (if any).
* @returns The plugin or `null`.
*/
protected _getAutoMediaPlugin(): AutoMediaType | null {
return this._carousel?.plugins()['autoMedia'] ?? null;
return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null;
}
/**
* Get the LazyLoad plugin (if any).
* @returns The plugin or `null`.
* Play the media on the selected slide.
*/
protected _getLazyLoadPlugin(): LazyloadType | null {
return this._carousel?.plugins()['lazyload'] ?? null;
public autoPlay(): void {
if (this.autoPlayCondition && ['all', 'selected'].includes(this.autoPlayCondition)) {
this._getAutoMediaPlugin()?.play();
}
}
/**
* Play the media on the selected slide. May be overridden to control when
* autoplay should happen.
* Pause the media on the selected slide.
*/
protected _autoPlayHandler(): void {
this._getAutoMediaPlugin()?.play();
public autoPause(): void {
if (
this.autoPauseCondition &&
['all', 'selected'].includes(this.autoPauseCondition)
) {
this._getAutoMediaPlugin()?.pause();
}
}
/**
* Unmute the media on the selected slide. May be overridden to control when
* autoplay should happen.
* Unmute the media on the selected slide.
*/
protected _autoUnmuteHandler(): void {
this._getAutoMediaPlugin()?.unmute();
public autoUnmute(): void {
if (
this.autoUnmuteCondition &&
['all', 'selected'].includes(this.autoUnmuteCondition)
) {
this._getAutoMediaPlugin()?.unmute();
}
}
/**
* Mute the media on the selected slide.
*/
public autoMute(): void {
if (this.autoMuteCondition && ['all', 'selected'].includes(this.autoMuteCondition)) {
this._getAutoMediaPlugin()?.mute();
}
}
/**
@@ -95,8 +224,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
// Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel (inc.
// adaptive height which has `0.5s ease`, see `media-carousel.scss`).
// and allows for the browser to finish rendering the carousel.
this._titleTimerID = window.setTimeout(show, 0.5 * 1000);
}
@@ -105,8 +233,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
*/
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('frigate-card:media-show', this._autoPlayHandler);
this.addEventListener('frigate-card:media-show', this._autoUnmuteHandler);
this.addEventListener('frigate-card:media-show', this.autoPlay);
this.addEventListener('frigate-card:media-show', this.autoUnmute);
this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
this.addEventListener('frigate-card:media-show', this._titleHandler);
this._resizeObserver.observe(this);
@@ -118,8 +246,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('frigate-card:media-show', this._autoPlayHandler);
this.removeEventListener('frigate-card:media-show', this._autoUnmuteHandler);
this.removeEventListener('frigate-card:media-show', this.autoPlay);
this.removeEventListener('frigate-card:media-show', this.autoUnmute);
this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
this.removeEventListener('frigate-card:media-show', this._titleHandler);
this._resizeObserver.disconnect();
@@ -143,9 +271,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
*/
const reInit = (): void => {
// Safari appears to not loop the carousel unless the options are passed
// back in during re-initialization.
this._carousel?.reInit(this._getOptions());
this.frigateCardCarousel()?.carouselReInit();
};
if (entries.some((entry) => entry.isIntersecting)) {
@@ -160,57 +286,21 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
}
}
protected _destroyCarousel(): void {
super._destroyCarousel();
// Notes on instance variables:
// * this._mediaShowInfo: This is set when the media in the DOM loads. If a
// new View included the same media, the DOM would not change and so the
// prior contents would still be valid and would not re-appear (as the
// media would not reload) -- as such, leave this alone on carousel
// destroy. New media in that slide will replace the prior contents on
// load.
}
/**
* Initialize the carousel.
*/
protected _initCarousel(): void {
super._initCarousel();
// Necessary because typescript local type narrowing is not paying attention
// to the side-effect of the call to super._initCarousel().
const carousel = this._carousel as EmblaCarouselType | undefined;
// Update the view object as the carousel is moved.
carousel?.on('select', this._selectSlideSetViewHandler.bind(this));
// Update the next/previous controls as the carousel is moved.
carousel?.on('select', this._selectSlideNextPreviousHandler.bind(this));
// Dispatch MediaShow events as the carousel is moved.
carousel?.on('init', this._selectSlideMediaShowHandler.bind(this));
carousel?.on('select', this._selectSlideMediaShowHandler.bind(this));
}
/**
* Set the the height of the container on media load in case the dimensions
* Set the the height of the component on media load in case the dimensions
* have changed. This handler is not triggered from carousel events, as it's
* actually the media load/show that will change the dimensions, and that is
* async from carousel actions (e.g. lazy-loaded media).
*/
protected _adaptiveHeightHandler(): void {
const adaptCarouselHeight = (): void => {
if (!this._carousel) {
return;
}
const slide = this._carousel?.selectedScrollSnap();
const slide = this.frigateCardCarousel()?.carouselSelected();
if (slide !== undefined) {
this._carousel.containerNode().style.removeProperty('max-height');
const slides = this._carousel.slideNodes();
const height = slides[slide].getBoundingClientRect().height;
if (height > 0) {
this._carousel.containerNode().style.maxHeight = `${height}px`;
this.style.removeProperty('max-height');
const currentSlide = this.frigateCardCarousel()?.carouselSelectedElement();
const height = currentSlide?.getBoundingClientRect().height;
if (height !== undefined && height > 0) {
this.style.maxHeight = `${height}px`;
}
}
};
@@ -225,42 +315,12 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
window.requestAnimationFrame(adaptCarouselHeight);
}
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _selectSlideSetViewHandler(): void {
// To be overridden in children.
}
/**
* Handle updating of the next/previous controls when the carousel is moved.
*/
protected _selectSlideNextPreviousHandler(): void {
// To be overridden in children.
}
/**
* 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(this._getTransitionEffect() === 'none');
} else if (direction === 'next') {
this._carousel?.scrollNext(this._getTransitionEffect() === 'none');
}
}
/**
* Fire a media show event when a slide is selected.
*/
protected _selectSlideMediaShowHandler(): void {
if (!this._carousel) {
return;
}
const slideIndex = this._carousel.selectedScrollSnap();
if (slideIndex in this._mediaShowInfo) {
protected _dispatchMediaShowInfo(): void {
const slideIndex = this.frigateCardCarousel()?.carouselSelected();
if (slideIndex !== undefined && slideIndex in this._mediaShowInfo) {
dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]);
}
}
@@ -271,46 +331,58 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
* @param slideIndex The relevant slide index.
* @param event The media-show event from the child component.
*/
protected _mediaShowEventHandler(
slideIndex: number,
event: CustomEvent<MediaShowInfo>,
): void {
protected _storeMediaShowInfo(event: CustomEvent<CarouselMediaShowInfo>): void {
// Don't allow the inbound event to propagate upwards, that will be
// automatically done at the appropriate time as the slide is shown.
event.stopPropagation();
this._mediaLoadedHandler(slideIndex, event.detail);
}
const mediaShowInfo = event.detail.mediaShowInfo;
const slideIndex = event.detail.slide;
/**
* Handle a MediaShowInfo object that is generated on media load, by saving it
* for future, or immediate use, when the relevant slide is displayed.
* @param slideIndex The relevant slide index.
* @param mediaShowInfo The MediaShowInfo object generated by the media.
*/
protected _mediaLoadedHandler(
slideIndex: number,
mediaShowInfo?: MediaShowInfo | null,
): void {
// isValidMediaShowInfo is used to prevent saving media info that will be
// rejected upstream (empty 1x1 images will be rejected here).
if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) {
this._mediaShowInfo[slideIndex] = mediaShowInfo;
if (this._carousel && this._carousel?.selectedScrollSnap() === slideIndex) {
if (this.frigateCardCarousel()?.carouselSelected() === slideIndex) {
dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo);
}
}
}
protected render(): TemplateResult | void {
return html` <frigate-card-carousel
${ref(this._refCarousel)}
.carouselOptions=${this.carouselOptions}
.carouselPlugins=${this.carouselPlugins}
transitionEffect=${ifDefined(this.transitionEffect)}
@frigate-card:carousel:init=${this._dispatchMediaShowInfo.bind(this)}
@frigate-card:carousel:select=${this._dispatchMediaShowInfo.bind(this)}
@frigate-card:carousel:media-show=${this._storeMediaShowInfo.bind(this)}
>
<slot slot="previous" name="previous"></slot>
<slot></slot>
<slot slot="next" name="next"></slot>
</frigate-card-carousel>
${this.label && this.titlePopupConfig
? html`<frigate-card-title-control
${ref(this._titleControlRef)}
.config=${this.titlePopupConfig}
.text="${this.label}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return [super.styles, unsafeCSS(mediaCarouselStyle)];
return unsafeCSS(mediaCarouselStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
"frigate-card-media-carousel": FrigateCardMediaCarousel
}
interface HTMLElementTagNameMap {
'frigate-card-media-carousel': FrigateCardMediaCarousel;
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ export class FrigateCardSurround extends LitElement {
.selected=${this.view.childIndex}
.cameras=${this.cameras}
@frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
@frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
// Send the view change from the source of the tap event, so the
// view change will be caught by the handler above (to close the drawer).
this.view
+76 -50
View File
@@ -1,17 +1,31 @@
import { HomeAssistant } from 'custom-card-helpers';
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { CSSResultGroup, html, PropertyValues, TemplateResult, unsafeCSS } from 'lit';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import type { CameraConfig, FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
import {
CameraConfig,
FrigateBrowseMediaSource,
ThumbnailsControlConfig,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { isTrueMedia } from '../utils/ha/browse-media';
import { View } from '../view.js';
import { FrigateCardCarousel } from './carousel.js';
import './thumbnail.js';
import './carousel.js';
import { ifDefined } from 'lit/directives/if-defined.js';
export interface ThumbnailCarouselTap {
slideIndex: number;
@@ -20,7 +34,7 @@ export interface ThumbnailCarouselTap {
}
@customElement('frigate-card-thumbnail-carousel')
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@@ -35,6 +49,8 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
// media loads). The carousel must be re-initialized in these cases, or the
// dynamic sizing fails (and users can scroll past the end of the carousel).
@@ -55,13 +71,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
}
@property({ attribute: false })
set config(config: ThumbnailsControlConfig) {
this.direction = ['left', 'right'].includes(config.mode) ? 'vertical' : 'horizontal';
this._config = config;
}
@state()
protected _config?: ThumbnailsControlConfig;
public config?: ThumbnailsControlConfig;
@state()
protected _selected?: number | null;
@@ -70,13 +80,12 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
* Handle gallery resize.
*/
protected _resizeHandler(): void {
if (this._carousel) {
this._carousel.reInit();
// Reinit will cause the scroll position to reset, so re-scroll to the
// correct location.
if (this._selected !== undefined && this._selected !== null) {
this.carouselScrollTo(this._selected);
}
this._refCarousel.value?.carouselReInit();
// Reinit will cause the scroll position to reset, so re-scroll to the
// correct location.
if (this._selected !== undefined && this._selected !== null) {
this._refCarousel.value?.carouselScrollTo(this._selected);
}
}
@@ -114,7 +123,6 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
*/
protected _getPlugins(): EmblaPluginType[] {
return [
...super._getPlugins(),
// Only enable wheel plugin if there is more than one camera.
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
@@ -148,12 +156,15 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('_config')) {
if (this._config?.size) {
this.style.setProperty(
'--frigate-card-thumbnail-size',
`${this._config.size}px`,
);
if (changedProps.has('config')) {
if (this.config?.size) {
this.style.setProperty('--frigate-card-thumbnail-size', `${this.config.size}px`);
}
const direction = this._getDirection();
if (direction) {
this.setAttribute('direction', direction);
} else {
this.removeAttribute('direction');
}
}
}
@@ -163,17 +174,12 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
if (changedProperties.has('target')) {
this._destroyCarousel();
}
super.updated(changedProperties);
if (changedProperties.has('_selected')) {
this.updateComplete.then(() => {
if (this._carousel) {
if (this._selected !== undefined && this._selected !== null) {
this.carouselScrollTo(this._selected);
}
if (this._selected !== undefined && this._selected !== null) {
this._refCarousel.value?.carouselScrollTo(this._selected);
}
});
}
@@ -209,17 +215,21 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
.target=${parent}
.childIndex=${childIndex}
.clientID=${cameraConfig?.frigate.client_id}
?details=${this._config?.show_details}
?show_favorite_control=${this._config?.show_favorite_control}
?show_timeline_control=${this._config?.show_timeline_control}
?details=${this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
class="${classMap(classes)}"
@click=${(ev) => {
if (this._carousel && this._carousel.clickAllowed()) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>(this, 'carousel:tap', {
slideIndex: slideIndex,
target: parent,
childIndex: childIndex,
});
if (this._refCarousel.value?.carouselClickAllowed()) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
this,
'thumbnail-carousel:tap',
{
slideIndex: slideIndex,
target: parent,
childIndex: childIndex,
},
);
}
stopEventFromActivatingCardWideActions(ev);
}}
@@ -227,33 +237,49 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
</frigate-card-thumbnail>`;
}
/**
* Get the direction of the thumbnail carousel.
* @returns `vertical`, `horizontal` or undefined.
*/
protected _getDirection(): 'horizontal' | 'vertical' | undefined {
if (this.config?.mode === 'left' || this.config?.mode === 'right') {
return 'vertical';
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
return 'horizontal';
}
return undefined;
}
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
const slides = this._getSlides();
if (!slides.length || !this._config || this._config.mode == 'none') {
if (!slides.length || !this.config || this.config.mode === 'none') {
return;
}
return html` <div class="embla">
<div class="embla__viewport">
<div class="embla__container">${slides}</div>
</div>
</div>`;
return html`<frigate-card-carousel
${ref(this._refCarousel)}
direction=${ifDefined(this._getDirection())}
.carouselOptions=${this._getOptions()}
.carouselPlugins=${this._getPlugins()}
>
${slides}
</frigate-card-carousel>`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return [super.styles, unsafeCSS(thumbnailCarouselStyle)];
return unsafeCSS(thumbnailCarouselStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
"frigate-card-thumbnail-carousel": FrigateCardThumbnailCarousel
}
interface HTMLElementTagNameMap {
'frigate-card-thumbnail-carousel': FrigateCardThumbnailCarousel;
}
}
+104 -140
View File
@@ -11,18 +11,20 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { ref } from 'lit/directives/ref.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import {
dispatchFrigateCardErrorEvent,
renderProgressIndicator,
} from '../components/message.js';
import viewerStyle from '../scss/viewer.scss';
import type {
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import {
BrowseMediaNeighbors,
BrowseMediaQueryParameters,
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
frigateCardConfigDefaults,
FrigateCardMediaPlayer,
MediaShowInfo,
TransitionEffect,
@@ -39,13 +41,16 @@ import {
overrideMultiBrowseMediaQueryParameters,
} from '../utils/ha/browse-media.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { createMediaShowInfo } from '../utils/media-info.js';
import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js';
import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js';
import {
FrigateCardMediaCarousel,
IMG_EMPTY,
wrapMediaLoadEventForCarousel,
wrapMediaShowEventForCarousel,
} from './media-carousel.js';
import './next-prev-control.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import './title-control.js';
import '../patches/ha-hls-player';
import './surround-thumbnails';
@@ -133,7 +138,7 @@ export class FrigateCardViewer extends LitElement {
const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
@customElement('frigate-card-viewer-carousel')
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@@ -154,6 +159,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
// Mapping of slide # to FrigateBrowseMediaSource child #.
// (Folders are not media items that can be rendered).
protected _slideToChild: Record<number, number> = {};
@@ -187,22 +194,20 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
if (this._carousel && changedProperties.has('viewerConfig')) {
this._destroyCarousel();
}
const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel();
if (this._carousel && changedProperties.has('view')) {
if (frigateCardCarousel && changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined;
if (oldView) {
if (oldView.target !== this.view?.target) {
// If the media target is different entirely, reset the carousel.
this._destroyCarousel();
} else if (this.view.childIndex != oldView.childIndex) {
if (
oldView.target === this.view?.target &&
this.view.childIndex != oldView.childIndex
) {
const slide = this._getSlideForChild(this.view.childIndex);
if (slide !== null && slide !== this.carouselSelected()) {
if (slide !== null && slide !== frigateCardCarousel.carouselSelected()) {
// If the media target is the same as already loaded, but isn't of
// the selected slide, scroll to that slide.
this.carouselScrollTo(slide);
frigateCardCarousel.carouselScrollTo(slide);
}
}
}
@@ -211,41 +216,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
super.updated(changedProperties);
}
/**
* Play the media on the loaded slide.
*/
protected _autoPlayHandler(): void {
if (
this.viewerConfig?.auto_play &&
['all', 'selected'].includes(this.viewerConfig.auto_play)
) {
super._autoPlayHandler();
}
}
/**
* Unmute the media on the loaded slide.
*/
protected _autoUnmuteHandler(): void {
if (
this.viewerConfig?.auto_unmute &&
['all', 'selected'].includes(this.viewerConfig.auto_unmute)
) {
super._autoUnmuteHandler();
}
}
/**
* Destroy the carousel.
*/
protected _destroyCarousel(): void {
super._destroyCarousel();
// Notes on instance variables:
// * this._slideToChild: This is set as part of each render and does not
// need to be destroyed here.
}
/**
* Get the slide number given a media child number.
* @param childIndex The child index (relative to `view.target`)
@@ -265,8 +235,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
protected _getTransitionEffect(): TransitionEffect | undefined {
return this.viewerConfig?.transition_effect;
protected _getTransitionEffect(): TransitionEffect {
return (
this.viewerConfig?.transition_effect ??
frigateCardConfigDefaults.media_viewer.transition_effect
);
}
/**
@@ -286,16 +259,16 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* @param slide An optional slide.
* @returns The FrigateCardMediaPlayer or null if not found.
*/
protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null {
if (this._carousel) {
if (!slide) {
slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()];
}
return slide?.querySelector(
FRIGATE_CARD_HLS_SELECTOR,
) as FrigateCardMediaPlayer | null;
protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
if (!slide) {
slide = this._refMediaCarousel.value
?.frigateCardCarousel()
?.carouselSelectedElement();
}
return null;
return (
(slide?.querySelector(FRIGATE_CARD_HLS_SELECTOR) as FrigateCardMediaPlayer) ?? null
);
}
/**
@@ -304,7 +277,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
*/
protected _getPlugins(): EmblaPluginType[] {
return [
...super._getPlugins(),
// Only enable wheel plugin if there is more than one media item.
...(this.view &&
this.view.target &&
@@ -480,15 +452,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _selectSlideSetViewHandler(): void {
if (!this._carousel || !this.view) {
protected _setViewHandler(): void {
if (!this._refMediaCarousel.value || !this.view) {
return;
}
// Update the childIndex in the view.
const slidesInView = this._carousel.slidesInView(true);
if (slidesInView.length) {
const childIndex = this._slideToChild[slidesInView[0]];
const selected = this._refMediaCarousel.value
.frigateCardCarousel()
?.carouselSelected();
if (selected !== undefined) {
const childIndex = this._slideToChild[selected];
if (childIndex !== undefined) {
this.view
.evolve({
@@ -556,31 +530,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
});
}
/**
* Handle updating of the next/previous controls when the carousel is moved.
*/
protected _selectSlideNextPreviousHandler(): void {
const updateNextPreviousControl = (
control: FrigateCardNextPreviousControl,
direction: 'previous' | 'next',
): void => {
const neighbors = this._getMediaNeighbors();
const [prev, next] = [neighbors?.previous, neighbors?.next];
const target = direction == 'previous' ? prev : next;
control.disabled = target == null;
control.title = target && target.title ? target.title : '';
control.thumbnail = target && target.thumbnail ? target.thumbnail : undefined;
};
if (this._previousControlRef.value) {
updateNextPreviousControl(this._previousControlRef.value, 'previous');
}
if (this._nextControlRef.value) {
updateNextPreviousControl(this._nextControlRef.value, 'next');
}
}
/**
* Get slides to include in the render.
* @returns The slides to include in the render and an index keyed by slide
@@ -647,59 +596,59 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
protected _render(): TemplateResult | void {
const [slides, slideToChild] = this._getSlides();
this._slideToChild = slideToChild;
if (!slides.length) {
if (!slides.length || !this.view?.media) {
return;
}
const neighbors = this._getMediaNeighbors();
const [prev, next] = [neighbors?.previous, neighbors?.next];
return html`<div class="embla">
<frigate-card-next-previous-control
${ref(this._previousControlRef)}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
.label=${prev ? prev.title : ''}
?disabled=${!prev}
@click=${(ev) => {
this._nextPreviousHandler('previous');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
<div class="embla__viewport">
<div class="embla__container">${slides}</div>
</div>
<frigate-card-next-previous-control
${ref(this._nextControlRef)}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
.label=${next ? next.title : ''}
?disabled=${!next}
@click=${(ev) => {
this._nextPreviousHandler('next');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
</div>
${this.view?.media
? html` <frigate-card-title-control
${ref(this._titleControlRef)}
.config=${this.viewerConfig?.controls.title}
.text="${this.view.media.title}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control>`
: ``} `;
return html` <frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${this._getOptions()}
.carouselPlugins=${this._getPlugins()}
.autoPlayCondition=${this.viewerConfig?.auto_play}
.autoPauseCondition=${this.viewerConfig?.auto_pause}
.autoMuteCondition=${this.viewerConfig?.auto_mute}
.autoUnmuteCondition=${this.viewerConfig?.auto_unmute}
.label="${this.view.media.title}"
.titlePopupConfig=${this.viewerConfig?.controls.title}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:media-show=${this._recordingSeekHandler.bind(this)}
>
<frigate-card-next-previous-control
slot="previous"
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
.label=${prev ? prev.title : ''}
?disabled=${!prev}
@click=${(ev) => {
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${slides}
<frigate-card-next-previous-control
slot="next"
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
.label=${next ? next.title : ''}
?disabled=${!next}
@click=${(ev) => {
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
</frigate-card-media-carousel>`;
}
/**
* Fire a media show event when a slide is selected.
*/
protected _selectSlideMediaShowHandler(): void {
super._selectSlideMediaShowHandler();
protected _recordingSeekHandler(): void {
// If this is a recording and play is desired to be started from a
// particular point, seek to that point. Use the media off the slide itself
// -- when the slide is changed, the media show event may be dispatched
@@ -751,8 +700,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
)}
.media=${mediaToRender}
.hass=${this.hass}
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
this._mediaShowEventHandler(slideIndex, e)}
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => {
wrapMediaShowEventForCarousel(slideIndex, e);
}}
>
</frigate-card-ha-hls-player>`
: html`<img
@@ -762,7 +712,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
)}
title="${mediaToRender.title}"
@click=${() => {
if (this._carousel?.clickAllowed()) {
if (
this._refMediaCarousel.value
?.frigateCardCarousel()
?.carouselClickAllowed()
) {
this._findRelatedClipView(mediaToRender).then((view) => {
if (view) {
view.dispatchChangeEvent(this);
@@ -771,6 +725,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
}
}}
@load="${(e: Event) => {
const lazyloadPlugin = this._refMediaCarousel.value
?.frigateCardCarousel()
?.getCarouselPlugins()?.lazyload;
if (
// This handler will be called on the empty image (including
// an updated empty image that is the same dimensions large as
@@ -778,15 +735,22 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
// images in media-carousel.ts). Here we need to only call the
// media load handler on a 'real' load.
!lazyLoad ||
this._getLazyLoadPlugin()?.hasLazyloaded(slideIndex)
lazyloadPlugin?.hasLazyloaded(slideIndex)
) {
this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e));
wrapMediaLoadEventForCarousel(slideIndex, e);
}
}}"
/>`}
</div>
`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(viewerCarouselStyle);
}
}
declare global {