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
+4
View File
@@ -2676,6 +2676,10 @@ See [screenshot above](#screenshots-card-casting).
You must be using a version of the [Frigate integration](https://github.com/blakeblackshear/frigate-hass-integration) >= 3.0.0-rc.2 to see recordings. Using an older version of the integration may also show blank thumbnails in the events viewer. Please upgrade your integration accordingly.
### Chrome autoplays when a tab becomes visible again
Even if `live.auto_play` or `media_viewer.auto_play` is set to `never`, Chrome itself will still auto play a video that was previously playing prior to the tab being hidden, once that tab is visible again. This behavior cannot be influenced by the card. Other browsers (e.g. Firefox, Safari) do not exhibit this behavior.
<a name="jsmpeg-troubleshooting"></a>
### JSMPEG Live Camera Only Shows A 'spinner'
+2 -2
View File
@@ -23,8 +23,8 @@
"crypto": "^1.0.1",
"custom-card-helpers": "^1.9.0",
"date-fns": "^2.28.0",
"embla-carousel": "^7.0.0-rc01",
"embla-carousel-wheel-gestures": "^2.1.1",
"embla-carousel": "^7.0.0-rc04",
"embla-carousel-wheel-gestures": "^3.0.0-rc01",
"home-assistant-js-websocket": "^7.1.0",
"keycharm": "^0.4.0",
"lit": "^2.2.5",
+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 {
+5 -20
View File
@@ -4,16 +4,9 @@
width: 100%;
}
img,video {
width: 100%;
height: 100%;
display: block;
}
.embla {
width: 100%;
height: 100%;
position: relative;
margin-left: auto;
margin-right: auto;
}
@@ -28,10 +21,10 @@ img,video {
-khtml-user-select: none;
-webkit-tap-highlight-color: transparent;
}
:host([direction=vertical]) .embla__container {
:host([direction='vertical']) .embla__container {
flex-direction: column;
}
:host([direction=horizontal]) .embla__container {
:host([direction='horizontal']) .embla__container {
flex-direction: row;
}
@@ -53,18 +46,10 @@ img,video {
cursor: grabbing;
}
.embla__slide {
position: relative;
overflow: visible;
}
:host([direction=vertical]) .embla__slide {
:host([direction='vertical']) ::slotted(.embla__slide) {
margin-bottom: 5px;
}
:host([direction=horizontal]) .embla__slide {
:host([direction='horizontal']) ::slotted(.embla__slide) {
margin-right: 5px;
}
.embla__slide img,video {
// Letterbox media. <frigate-card-ha-hls-player> has similar added directly in
// its element.
object-fit: contain;
}
+4
View File
@@ -0,0 +1,4 @@
.embla__slide {
height: 100%;
flex: 0 0 100%;
}
+7 -6
View File
@@ -1,8 +1,9 @@
:host {
--video-max-height: none;
}
.embla__slide {
flex: 0 0 100%;
display: block;
width: 100%;
height: 100%;
}
--video-max-height: none;
// Keep the controls relative to the media carousel itself.
position: relative;
}
+1 -1
View File
@@ -22,7 +22,7 @@
}
.controls.icons {
top: calc(50% - (40px / 2));
top: calc(50% - (var(--frigate-card-next-prev-size) / 2));
}
.controls.thumbnails {
+4
View File
@@ -1,6 +1,10 @@
@use 'const.scss';
:host {
display: block;
width: 100%;
height: 100%;
--frigate-card-carousel-thumbnail-opacity: 1;
}
+4
View File
@@ -3,6 +3,10 @@
overflow: hidden;
}
img {
display: block;
}
img,
ha-icon {
border-radius: var(--ha-card-border-radius, 4px);
+14
View File
@@ -0,0 +1,14 @@
.embla__slide {
height: 100%;
flex: 0 0 100%;
}
.embla__slide img {
display: block;
width: 100%;
height: 100%;
// Letterbox media. <frigate-card-ha-hls-player> has similar added directly in
// its element.
object-fit: contain;
}
+8 -8
View File
@@ -1155,17 +1155,17 @@ electron-to-chromium@^1.4.147:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.161.tgz#49cb5b35385bfee6cc439d0a04fbba7a7a7f08a1"
integrity sha512-sTjBRhqh6wFodzZtc5Iu8/R95OkwaPNn7tj/TaDU5nu/5EFiQDtADGAXdR4tJcTEHlYfJpHqigzJqHvPgehP8A==
embla-carousel-wheel-gestures@^2.1.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-2.2.0.tgz#04ee1cfafe0667a5b96d16341642b3124fe8894e"
integrity sha512-IoRGblg8QWrIgZEW0NbDcIl2fO++BLFf6197k2JNair3pfbyiMYtva6rROgeRiI0sIj2kFwlSEgudTy5f8TzNQ==
embla-carousel-wheel-gestures@^3.0.0-rc01:
version "3.0.0-rc01"
resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-3.0.0-rc01.tgz#70f88d6ee755817270ca26514d06b7c08c12977d"
integrity sha512-h6E1/AwGKEwro8pey6KeOnt/UMvSaCwJKxaA+sz4OERPLmVL06oejVJ2kMjL06y9WaxZ8TqKWR7m0HlbsI9F5A==
dependencies:
wheel-gestures "^2.2.5"
embla-carousel@^7.0.0-rc01:
version "7.0.0-rc01"
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc01.tgz#7c9adfd7302b85c2de9354b7ef6343f16a696eb7"
integrity sha512-IBTSKcPw7u9K0zoLvsnWYsijKsI0msFqzNp6ASIthTXiMZNJNGQt8k0ax7KqUVinDZeNM07WPWqYsjrvUM/Epw==
embla-carousel@^7.0.0-rc04:
version "7.0.0-rc04"
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc04.tgz#bd9c15da7740660b46232fba720b0c38043b667f"
integrity sha512-vhzwCEdEqwS5c6jlfPHy/X1uUkwf9AvMma8KWNbmTgB3NUNN9hoqiyTdrpraMtPU5MzlxpZipln74ZwDK4v63g==
emojis-list@^3.0.0:
version "3.0.0"