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. 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> <a name="jsmpeg-troubleshooting"></a>
### JSMPEG Live Camera Only Shows A 'spinner' ### JSMPEG Live Camera Only Shows A 'spinner'
+2 -2
View File
@@ -23,8 +23,8 @@
"crypto": "^1.0.1", "crypto": "^1.0.1",
"custom-card-helpers": "^1.9.0", "custom-card-helpers": "^1.9.0",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"embla-carousel": "^7.0.0-rc01", "embla-carousel": "^7.0.0-rc04",
"embla-carousel-wheel-gestures": "^2.1.1", "embla-carousel-wheel-gestures": "^3.0.0-rc01",
"home-assistant-js-websocket": "^7.1.0", "home-assistant-js-websocket": "^7.1.0",
"keycharm": "^0.4.0", "keycharm": "^0.4.0",
"lit": "^2.2.5", "lit": "^2.2.5",
+156 -38
View File
@@ -1,10 +1,20 @@
import EmblaCarousel, { import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel';
EmblaCarouselType, import { EmblaNodesType } from 'embla-carousel/components';
EmblaOptionsType, import {
EmblaPluginType CreatePluginType,
} from 'embla-carousel'; EmblaPluginsType,
import { CSSResultGroup, LitElement, PropertyValues, unsafeCSS } from 'lit'; LoosePluginType,
import { property } from 'lit/decorators.js'; } 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 carouselStyle from '../scss/carousel.scss';
import { TransitionEffect } from '../types'; import { TransitionEffect } from '../types';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
@@ -13,28 +23,120 @@ export interface CarouselSelect {
index: number; index: number;
} }
export type EmblaCarouselPlugins = CreatePluginType<
LoosePluginType,
Record<string, unknown>
>[];
@customElement('frigate-card-carousel')
export class FrigateCardCarousel extends LitElement { export class FrigateCardCarousel extends LitElement {
@property({ attribute: true, reflect: true }) @property({ attribute: true, reflect: true })
public direction: 'vertical' | 'horizontal' = 'horizontal'; 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; 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. * Scroll to a particular slide.
* @param index Slide number. * @param index Slide number.
*/ */
carouselScrollTo(index: number): void { public carouselScrollTo(index: number): void {
this._carousel?.scrollTo(index, this._getTransitionEffect() === 'none'); 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. * Get the selected slide.
* @returns The slide index or undefined if the carousel is not loaded. * @returns The slide index or undefined if the carousel is not loaded.
*/ */
carouselSelected(): number | undefined { public carouselSelected(): number | undefined {
return this._carousel?.selectedScrollSnap(); 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. * The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render. * @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 { protected _destroyCarousel(): void {
if (this._carousel) { if (this._carousel) {
this._carousel.destroy(); this._carousel.destroy();
@@ -91,14 +169,21 @@ export class FrigateCardCarousel extends LitElement {
'.embla__viewport', '.embla__viewport',
) as HTMLElement; ) 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( this._carousel = EmblaCarousel(
carouselNode, nodes,
{ {
axis: this.direction == 'horizontal' ? 'x' : 'y', 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('init', () => dispatchFrigateCardEvent(this, 'carousel:init'));
this._carousel.on('select', () => { 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. * Get element styles.
*/ */
@@ -119,3 +231,9 @@ export class FrigateCardCarousel extends LitElement {
return unsafeCSS(carouselStyle); return unsafeCSS(carouselStyle);
} }
} }
declare global {
interface HTMLElementTagNameMap {
'frigate-card-carousel': FrigateCardCarousel;
}
}
+8 -2
View File
@@ -39,6 +39,12 @@ export type AutoMediaType = CreatePluginType<
AutoMediaOptionsType 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). * An Embla plugin to take automated actions on media (e.g. pause, unmute, etc).
* @param userOptions * @param userOptions
@@ -112,7 +118,7 @@ export function AutoMediaPlugin(
* Handle document visibility changes. * Handle document visibility changes.
*/ */
function visibilityHandler(): void { function visibilityHandler(): void {
if (document.visibilityState == 'hidden') { if (document.visibilityState === 'hidden') {
if ( if (
options.autoPauseCondition && options.autoPauseCondition &&
['all', 'hidden'].includes(options.autoPauseCondition) ['all', 'hidden'].includes(options.autoPauseCondition)
@@ -125,7 +131,7 @@ export function AutoMediaPlugin(
) { ) {
muteAll(); muteAll();
} }
} else if (document.visibilityState == 'visible') { } else if (document.visibilityState === 'visible') {
if ( if (
options.autoPlayCondition && options.autoPlayCondition &&
['all', 'visible'].includes(options.autoPlayCondition) ['all', 'visible'].includes(options.autoPlayCondition)
+7 -1
View File
@@ -19,7 +19,7 @@ export const defaultOptions: OptionsType = {
lazyLoadCount: 0, lazyLoadCount: 0,
}; };
export type LazyloadOptionsType = Partial<OptionsType> export type LazyloadOptionsType = Partial<OptionsType>;
export type LazyloadType = CreatePluginType< export type LazyloadType = CreatePluginType<
{ {
@@ -28,6 +28,12 @@ export type LazyloadType = CreatePluginType<
LazyloadOptionsType LazyloadOptionsType
>; >;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
lazyload?: LazyloadType;
}
}
export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
const optionsHandler = EmblaCarousel.optionsHandler(); const optionsHandler = EmblaCarousel.optionsHandler();
const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions); 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 { createRef, Ref, ref } from 'lit/directives/ref.js';
import { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
import { ConditionState, getOverriddenConfig } from '../card-condition.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 { localize } from '../localize/localize.js';
import liveFrigateStyle from '../scss/live-frigate.scss'; import liveFrigateStyle from '../scss/live-frigate.scss';
import liveJSMPEGStyle from '../scss/live-jsmpeg.scss'; import liveJSMPEGStyle from '../scss/live-jsmpeg.scss';
import liveWebRTCStyle from '../scss/live-webrtc.scss'; import liveWebRTCStyle from '../scss/live-webrtc.scss';
import liveStyle from '../scss/live.scss'; import liveStyle from '../scss/live.scss';
import liveCarouselStyle from '../scss/live-carousel.scss';
import { import {
CameraConfig, CameraConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
@@ -47,10 +51,9 @@ import {
import { View } from '../view.js'; import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.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 { dispatchErrorMessageEvent } from './message.js';
import './next-prev-control.js'; import './next-prev-control.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import './title-control.js'; import './title-control.js';
import './surround-thumbnails'; import './surround-thumbnails';
import '../patches/ha-camera-stream'; import '../patches/ha-camera-stream';
@@ -182,7 +185,7 @@ export class FrigateCardLive extends LitElement {
} }
@customElement('frigate-card-live-carousel') @customElement('frigate-card-live-carousel')
export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: ExtendedHomeAssistant; public hass?: ExtendedHomeAssistant;
@@ -206,40 +209,39 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
// Index between camera name and slide number. // Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {}; protected _cameraToSlide: Record<string, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
/** /**
* The updated lifecycle callback for this element. * The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render. * @param changedProperties The properties that were changed in this render.
*/ */
updated(changedProperties: PropertyValues): void { 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); super.updated(changedProperties);
const frigateCardMediaCarousel = this._refMediaCarousel.value;
const frigateCardCarousel = frigateCardMediaCarousel?.frigateCardCarousel();
if (changedProperties.has('view')) { if (changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined; const oldView = changedProperties.get('view') as View | undefined;
if ( if (
this._carousel && frigateCardCarousel &&
oldView && oldView &&
this.view?.camera && this.view?.camera &&
this.view?.camera != oldView.camera this.view?.camera != oldView.camera
) { ) {
const slide: number | undefined = this._cameraToSlide[this.view.camera]; const slide: number | undefined = this._cameraToSlide[this.view.camera];
if (slide !== undefined && slide !== this.carouselSelected()) { if (slide !== undefined && slide !== frigateCardCarousel.carouselSelected()) {
this.carouselScrollTo(slide); frigateCardCarousel.carouselScrollTo(slide);
} }
} }
} }
if (changedProperties.has('preloaded')) { if (
const automedia = this._getAutoMediaPlugin(); frigateCardMediaCarousel &&
frigateCardCarousel &&
changedProperties.has('preloaded')
) {
const automedia = frigateCardCarousel.getCarouselPlugins()?.autoMedia;
if (automedia) { if (automedia) {
// If this has changed to preloaded (i.e. is now loaded but in the // If this has changed to preloaded (i.e. is now loaded but in the
// background) take the appropriate play/pause/mute/unmute actions. // background) take the appropriate play/pause/mute/unmute actions.
@@ -257,8 +259,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
automedia.mute(); automedia.mute();
} }
} else { } else {
this._autoPlayHandler(); frigateCardMediaCarousel.autoPlay();
this._autoUnmuteHandler(); frigateCardMediaCarousel.autoUnmute();
} }
} }
} }
@@ -268,8 +270,11 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
* Get the transition effect to use. * Get the transition effect to use.
* @returns An TransitionEffect object. * @returns An TransitionEffect object.
*/ */
protected _getTransitionEffect(): TransitionEffect | undefined { protected _getTransitionEffect(): TransitionEffect {
return this.liveConfig?.transition_effect; return (
this.liveConfig?.transition_effect ??
frigateCardConfigDefaults.live.transition_effect
);
} }
/** /**
@@ -293,7 +298,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
*/ */
protected _getPlugins(): EmblaPluginType[] { protected _getPlugins(): EmblaPluginType[] {
return [ return [
...super._getPlugins(),
// Only enable wheel plugin if there is more than one camera. // Only enable wheel plugin if there is more than one camera.
...(this.cameras && this.cameras.size > 1 ...(this.cameras && this.cameras.size > 1
? [ ? [
@@ -314,6 +318,8 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
lazyUnloadCallback: (index, slide) => lazyUnloadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('unload', index, slide), this._lazyloadOrUnloadSlide('unload', index, slide),
}), }),
// TODO: AutoMediaPlugin could be moved to MediaCarousel.
AutoMediaPlugin({ AutoMediaPlugin({
playerSelector: 'frigate-card-live-provider', playerSelector: 'frigate-card-live-provider',
...(this.liveConfig?.auto_play && { ...(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 * 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 * 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. * Handle the user selecting a new slide in the carousel.
*/ */
protected _selectSlideSetViewHandler(): void { protected _setViewHandler(): void {
if (!this._carousel || !this.view || !this.cameras) { const selectedCameraIndex = this._refMediaCarousel.value
?.frigateCardCarousel()
?.carouselSelected();
if (selectedCameraIndex === undefined || !this.view || !this.cameras) {
return; return;
} }
const selectedSnap = this._carousel.selectedScrollSnap();
this.view this.view
.evolve({ .evolve({
camera: Array.from(this.cameras.keys())[selectedSnap], camera: Array.from(this.cameras.keys())[selectedCameraIndex],
// Reset the target so thumbnails will be re-fetched. // Reset the target so thumbnails will be re-fetched.
target: null, target: null,
@@ -419,9 +403,13 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
protected _lazyloadOrUnloadSlide( protected _lazyloadOrUnloadSlide(
action: 'load' | 'unload', action: 'load' | 'unload',
_index: number, _index: number,
slide: HTMLElement, slide: Element,
): void { ): void {
const liveProvider = slide.querySelector( if (slide instanceof HTMLSlotElement) {
slide = slide.assignedElements({flatten: true})[0];
}
const liveProvider = slide?.querySelector(
'frigate-card-live-provider', 'frigate-card-live-provider',
) as FrigateCardLiveProvider; ) as FrigateCardLiveProvider;
if (liveProvider) { if (liveProvider) {
@@ -451,18 +439,21 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
conditionState, conditionState,
) as LiveConfig; ) as LiveConfig;
return html` <div class="embla__slide"> return html`
<frigate-card-live-provider <div class="embla__slide">
?disabled=${this.liveConfig.lazy_load} <frigate-card-live-provider
.cameraConfig=${cameraConfig} ?disabled=${this.liveConfig.lazy_load}
.label=${getCameraTitle(this.hass, cameraConfig)} .cameraConfig=${cameraConfig}
.liveConfig=${config} .label=${getCameraTitle(this.hass, cameraConfig)}
.hass=${this.hass} .liveConfig=${config}
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => .hass=${this.hass}
this._mediaShowEventHandler(slideIndex, e)} @frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => {
> wrapMediaShowEventForCarousel(slideIndex, e)
</frigate-card-live-provider> }}
</div>`; >
</frigate-card-live-provider>
</div>
`;
} }
protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] { protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] {
@@ -487,30 +478,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
return [prev, next]; 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. * Render the element.
* @returns A template to display to the user. * @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)); const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera));
return html` 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 <frigate-card-next-previous-control
${ref(this._previousControlRef)} slot="previous"
.direction=${'previous'} .direction=${'previous'}
.controlConfig=${config.controls.next_previous} .controlConfig=${config.controls.next_previous}
.label=${getCameraTitle(this.hass, prev)} .label=${getCameraTitle(this.hass, prev)}
.icon=${getCameraIcon(this.hass, prev)} .icon=${getCameraIcon(this.hass, prev)}
?disabled=${prev == null} ?disabled=${prev == null}
@click=${(ev) => { @click=${(ev) => {
this._nextPreviousHandler('previous'); this._refMediaCarousel.value
?.frigateCardCarousel()
?.carouselScrollPrevious();
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
> >
</frigate-card-next-previous-control> </frigate-card-next-previous-control>
<div class="embla__viewport"> ${slides}
<div class="embla__container">${slides}</div>
</div>
<frigate-card-next-previous-control <frigate-card-next-previous-control
${ref(this._nextControlRef)} slot="next"
.direction=${'next'} .direction=${'next'}
.controlConfig=${config.controls.next_previous} .controlConfig=${config.controls.next_previous}
.label=${getCameraTitle(this.hass, next)} .label=${getCameraTitle(this.hass, next)}
.icon=${getCameraIcon(this.hass, next)} .icon=${getCameraIcon(this.hass, next)}
?disabled=${next == null} ?disabled=${next == null}
@click=${(ev) => { @click=${(ev) => {
this._nextPreviousHandler('next'); this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
> >
</frigate-card-next-previous-control> </frigate-card-next-previous-control>
</div> </frigate-card-media-carousel>
<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>
`; `;
} }
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveCarouselStyle);
}
} }
@customElement('frigate-card-live-provider') @customElement('frigate-card-live-provider')
@@ -747,20 +726,16 @@ export class FrigateCardLiveFrigate extends LitElement {
} }
if (!this.cameraConfig?.camera_entity) { if (!this.cameraConfig?.camera_entity) {
return dispatchErrorMessageEvent( return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), {
this, context: this.cameraConfig,
localize('error.no_live_camera'), });
{ context: this.cameraConfig },
);
} }
const stateObj = this.hass.states[this.cameraConfig.camera_entity]; const stateObj = this.hass.states[this.cameraConfig.camera_entity];
if (!stateObj || stateObj.state === 'unavailable') { if (!stateObj || stateObj.state === 'unavailable') {
return dispatchErrorMessageEvent( return dispatchErrorMessageEvent(this, localize('error.live_camera_unavailable'), {
this, context: this.cameraConfig,
localize('error.live_camera_unavailable'), });
{ context: this.cameraConfig },
);
} }
return html` <frigate-card-ha-camera-stream return html` <frigate-card-ha-camera-stream
@@ -1143,11 +1118,9 @@ export class FrigateCardLiveJSMPEG extends LitElement {
this._jsmpegCanvasElement.className = 'media'; this._jsmpegCanvasElement.className = 'media';
if (!this.cameraConfig?.frigate.camera_name) { if (!this.cameraConfig?.frigate.camera_name) {
return dispatchErrorMessageEvent( return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), {
this, context: this.cameraConfig,
localize('error.no_camera_name'), });
{ context: this.cameraConfig },
);
} }
const url = await this._getURL(); const url = await this._getURL();
+200 -128
View File
@@ -1,17 +1,32 @@
import { EmblaCarouselType } from 'embla-carousel'; // TODO: Use the auto-height plugin instead of adaptive height
import { CSSResultGroup, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js'; import { EmblaOptionsType } from 'embla-carousel';
import { createRef, Ref } from 'lit/directives/ref.js'; 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 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 { import {
createMediaShowInfo,
dispatchExistingMediaShowInfoAsEvent, dispatchExistingMediaShowInfoAsEvent,
isValidMediaShowInfo isValidMediaShowInfo,
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import { FrigateCardCarousel } from './carousel.js'; import { EmblaCarouselPlugins, FrigateCardCarousel } from './carousel';
import { AutoMediaType } from './embla-plugins/automedia.js'; import { AutoMediaType } from './embla-plugins/automedia.js';
import { LazyloadType } from './embla-plugins/lazyload';
import './next-prev-control.js'; import './next-prev-control.js';
import './carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-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`; `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 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') @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. // A "map" from slide number to MediaShowInfo object.
protected _mediaShowInfo: Record<number, MediaShowInfo> = {}; protected _mediaShowInfo: Record<number, MediaShowInfo> = {};
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef(); protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
@@ -28,12 +125,19 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef(); protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
protected _titleTimerID: number | null = null; 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, // This carousel may be resized by Lovelace resizes, window resizes,
// fullscreen, etc. Always call the adaptive height handler when the size // fullscreen, etc. Always call the adaptive height handler when the size
// changes. // changes.
protected _resizeObserver: ResizeObserver; protected _resizeObserver: ResizeObserver;
protected _intersectionObserver: IntersectionObserver; protected _intersectionObserver: IntersectionObserver;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
constructor() { constructor() {
super(); super();
this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this)); 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). * Get the AutoMedia plugin (if any).
* @returns The plugin or `null`. * @returns The plugin or `null`.
*/ */
protected _getAutoMediaPlugin(): AutoMediaType | null { protected _getAutoMediaPlugin(): AutoMediaType | null {
return this._carousel?.plugins()['autoMedia'] ?? null; return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null;
} }
/** /**
* Get the LazyLoad plugin (if any). * Play the media on the selected slide.
* @returns The plugin or `null`.
*/ */
protected _getLazyLoadPlugin(): LazyloadType | null { public autoPlay(): void {
return this._carousel?.plugins()['lazyload'] ?? null; if (this.autoPlayCondition && ['all', 'selected'].includes(this.autoPlayCondition)) {
this._getAutoMediaPlugin()?.play();
}
} }
/** /**
* Play the media on the selected slide. May be overridden to control when * Pause the media on the selected slide.
* autoplay should happen.
*/ */
protected _autoPlayHandler(): void { public autoPause(): void {
this._getAutoMediaPlugin()?.play(); if (
this.autoPauseCondition &&
['all', 'selected'].includes(this.autoPauseCondition)
) {
this._getAutoMediaPlugin()?.pause();
}
} }
/** /**
* Unmute the media on the selected slide. May be overridden to control when * Unmute the media on the selected slide.
* autoplay should happen.
*/ */
protected _autoUnmuteHandler(): void { public autoUnmute(): void {
this._getAutoMediaPlugin()?.unmute(); 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 // Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title, // displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel (inc. // and allows for the browser to finish rendering the carousel.
// adaptive height which has `0.5s ease`, see `media-carousel.scss`).
this._titleTimerID = window.setTimeout(show, 0.5 * 1000); this._titleTimerID = window.setTimeout(show, 0.5 * 1000);
} }
@@ -105,8 +233,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
*/ */
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this.addEventListener('frigate-card:media-show', this._autoPlayHandler); this.addEventListener('frigate-card:media-show', this.autoPlay);
this.addEventListener('frigate-card:media-show', this._autoUnmuteHandler); this.addEventListener('frigate-card:media-show', this.autoUnmute);
this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler); this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
this.addEventListener('frigate-card:media-show', this._titleHandler); this.addEventListener('frigate-card:media-show', this._titleHandler);
this._resizeObserver.observe(this); this._resizeObserver.observe(this);
@@ -118,8 +246,8 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
*/ */
disconnectedCallback(): void { disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this.removeEventListener('frigate-card:media-show', this._autoPlayHandler); this.removeEventListener('frigate-card:media-show', this.autoPlay);
this.removeEventListener('frigate-card:media-show', this._autoUnmuteHandler); this.removeEventListener('frigate-card:media-show', this.autoUnmute);
this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler); this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
this.removeEventListener('frigate-card:media-show', this._titleHandler); this.removeEventListener('frigate-card:media-show', this._titleHandler);
this._resizeObserver.disconnect(); this._resizeObserver.disconnect();
@@ -143,9 +271,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
*/ */
const reInit = (): void => { const reInit = (): void => {
// Safari appears to not loop the carousel unless the options are passed this.frigateCardCarousel()?.carouselReInit();
// back in during re-initialization.
this._carousel?.reInit(this._getOptions());
}; };
if (entries.some((entry) => entry.isIntersecting)) { 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. * Set the the height of the component on media load in case the dimensions
*/
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
* have changed. This handler is not triggered from carousel events, as it's * 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 * actually the media load/show that will change the dimensions, and that is
* async from carousel actions (e.g. lazy-loaded media). * async from carousel actions (e.g. lazy-loaded media).
*/ */
protected _adaptiveHeightHandler(): void { protected _adaptiveHeightHandler(): void {
const adaptCarouselHeight = (): void => { const adaptCarouselHeight = (): void => {
if (!this._carousel) { const slide = this.frigateCardCarousel()?.carouselSelected();
return;
}
const slide = this._carousel?.selectedScrollSnap();
if (slide !== undefined) { if (slide !== undefined) {
this._carousel.containerNode().style.removeProperty('max-height'); this.style.removeProperty('max-height');
const slides = this._carousel.slideNodes(); const currentSlide = this.frigateCardCarousel()?.carouselSelectedElement();
const height = slides[slide].getBoundingClientRect().height; const height = currentSlide?.getBoundingClientRect().height;
if (height > 0) { if (height !== undefined && height > 0) {
this._carousel.containerNode().style.maxHeight = `${height}px`; this.style.maxHeight = `${height}px`;
} }
} }
}; };
@@ -225,42 +315,12 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
window.requestAnimationFrame(adaptCarouselHeight); 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. * Fire a media show event when a slide is selected.
*/ */
protected _selectSlideMediaShowHandler(): void { protected _dispatchMediaShowInfo(): void {
if (!this._carousel) { const slideIndex = this.frigateCardCarousel()?.carouselSelected();
return; if (slideIndex !== undefined && slideIndex in this._mediaShowInfo) {
}
const slideIndex = this._carousel.selectedScrollSnap();
if (slideIndex in this._mediaShowInfo) {
dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]); dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]);
} }
} }
@@ -271,46 +331,58 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
* @param slideIndex The relevant slide index. * @param slideIndex The relevant slide index.
* @param event The media-show event from the child component. * @param event The media-show event from the child component.
*/ */
protected _mediaShowEventHandler( protected _storeMediaShowInfo(event: CustomEvent<CarouselMediaShowInfo>): void {
slideIndex: number,
event: CustomEvent<MediaShowInfo>,
): void {
// Don't allow the inbound event to propagate upwards, that will be // Don't allow the inbound event to propagate upwards, that will be
// automatically done at the appropriate time as the slide is shown. // automatically done at the appropriate time as the slide is shown.
event.stopPropagation(); 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 // isValidMediaShowInfo is used to prevent saving media info that will be
// rejected upstream (empty 1x1 images will be rejected here). // rejected upstream (empty 1x1 images will be rejected here).
if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) {
this._mediaShowInfo[slideIndex] = mediaShowInfo; this._mediaShowInfo[slideIndex] = mediaShowInfo;
if (this._carousel && this._carousel?.selectedScrollSnap() === slideIndex) { if (this.frigateCardCarousel()?.carouselSelected() === slideIndex) {
dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); 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. * Get element styles.
*/ */
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [super.styles, unsafeCSS(mediaCarouselStyle)]; return unsafeCSS(mediaCarouselStyle);
} }
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-media-carousel": FrigateCardMediaCarousel 'frigate-card-media-carousel': FrigateCardMediaCarousel;
} }
} }
+1 -1
View File
@@ -148,7 +148,7 @@ export class FrigateCardSurround extends LitElement {
.selected=${this.view.childIndex} .selected=${this.view.childIndex}
.cameras=${this.cameras} .cameras=${this.cameras}
@frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @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 // 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). // view change will be caught by the handler above (to close the drawer).
this.view this.view
+76 -50
View File
@@ -1,17 +1,31 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; 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 { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.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 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 { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { isTrueMedia } from '../utils/ha/browse-media'; import { isTrueMedia } from '../utils/ha/browse-media';
import { View } from '../view.js'; import { View } from '../view.js';
import { FrigateCardCarousel } from './carousel.js'; import { FrigateCardCarousel } from './carousel.js';
import './thumbnail.js'; import './thumbnail.js';
import './carousel.js';
import { ifDefined } from 'lit/directives/if-defined.js';
export interface ThumbnailCarouselTap { export interface ThumbnailCarouselTap {
slideIndex: number; slideIndex: number;
@@ -20,7 +34,7 @@ export interface ThumbnailCarouselTap {
} }
@customElement('frigate-card-thumbnail-carousel') @customElement('frigate-card-thumbnail-carousel')
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: HomeAssistant; public hass?: HomeAssistant;
@@ -35,6 +49,8 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
@property({ attribute: false }) @property({ attribute: false })
public cameras?: Map<string, CameraConfig>; public cameras?: Map<string, CameraConfig>;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main // 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 // 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). // 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 }) @property({ attribute: false })
set config(config: ThumbnailsControlConfig) { public config?: ThumbnailsControlConfig;
this.direction = ['left', 'right'].includes(config.mode) ? 'vertical' : 'horizontal';
this._config = config;
}
@state()
protected _config?: ThumbnailsControlConfig;
@state() @state()
protected _selected?: number | null; protected _selected?: number | null;
@@ -70,13 +80,12 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
* Handle gallery resize. * Handle gallery resize.
*/ */
protected _resizeHandler(): void { protected _resizeHandler(): void {
if (this._carousel) { this._refCarousel.value?.carouselReInit();
this._carousel.reInit();
// Reinit will cause the scroll position to reset, so re-scroll to the // Reinit will cause the scroll position to reset, so re-scroll to the
// correct location. // correct location.
if (this._selected !== undefined && this._selected !== null) { if (this._selected !== undefined && this._selected !== null) {
this.carouselScrollTo(this._selected); this._refCarousel.value?.carouselScrollTo(this._selected);
}
} }
} }
@@ -114,7 +123,6 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
*/ */
protected _getPlugins(): EmblaPluginType[] { protected _getPlugins(): EmblaPluginType[] {
return [ return [
...super._getPlugins(),
// Only enable wheel plugin if there is more than one camera. // Only enable wheel plugin if there is more than one camera.
WheelGesturesPlugin({ WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel // 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 * @param changedProps The changed properties
*/ */
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('_config')) { if (changedProps.has('config')) {
if (this._config?.size) { if (this.config?.size) {
this.style.setProperty( this.style.setProperty('--frigate-card-thumbnail-size', `${this.config.size}px`);
'--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. * @param changedProperties The properties that were changed in this render.
*/ */
updated(changedProperties: PropertyValues): void { updated(changedProperties: PropertyValues): void {
if (changedProperties.has('target')) {
this._destroyCarousel();
}
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('_selected')) { if (changedProperties.has('_selected')) {
this.updateComplete.then(() => { this.updateComplete.then(() => {
if (this._carousel) { if (this._selected !== undefined && this._selected !== null) {
if (this._selected !== undefined && this._selected !== null) { this._refCarousel.value?.carouselScrollTo(this._selected);
this.carouselScrollTo(this._selected);
}
} }
}); });
} }
@@ -209,17 +215,21 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
.target=${parent} .target=${parent}
.childIndex=${childIndex} .childIndex=${childIndex}
.clientID=${cameraConfig?.frigate.client_id} .clientID=${cameraConfig?.frigate.client_id}
?details=${this._config?.show_details} ?details=${this.config?.show_details}
?show_favorite_control=${this._config?.show_favorite_control} ?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this._config?.show_timeline_control} ?show_timeline_control=${this.config?.show_timeline_control}
class="${classMap(classes)}" class="${classMap(classes)}"
@click=${(ev) => { @click=${(ev) => {
if (this._carousel && this._carousel.clickAllowed()) { if (this._refCarousel.value?.carouselClickAllowed()) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>(this, 'carousel:tap', { dispatchFrigateCardEvent<ThumbnailCarouselTap>(
slideIndex: slideIndex, this,
target: parent, 'thumbnail-carousel:tap',
childIndex: childIndex, {
}); slideIndex: slideIndex,
target: parent,
childIndex: childIndex,
},
);
} }
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
@@ -227,33 +237,49 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
</frigate-card-thumbnail>`; </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. * Render the element.
* @returns A template to display to the user. * @returns A template to display to the user.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const slides = this._getSlides(); const slides = this._getSlides();
if (!slides.length || !this._config || this._config.mode == 'none') { if (!slides.length || !this.config || this.config.mode === 'none') {
return; return;
} }
return html` <div class="embla"> return html`<frigate-card-carousel
<div class="embla__viewport"> ${ref(this._refCarousel)}
<div class="embla__container">${slides}</div> direction=${ifDefined(this._getDirection())}
</div> .carouselOptions=${this._getOptions()}
</div>`; .carouselPlugins=${this._getPlugins()}
>
${slides}
</frigate-card-carousel>`;
} }
/** /**
* Get element styles. * Get element styles.
*/ */
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [super.styles, unsafeCSS(thumbnailCarouselStyle)]; return unsafeCSS(thumbnailCarouselStyle);
} }
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-thumbnail-carousel": FrigateCardThumbnailCarousel 'frigate-card-thumbnail-carousel': FrigateCardThumbnailCarousel;
} }
} }
+104 -140
View File
@@ -11,18 +11,20 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.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 { import {
dispatchFrigateCardErrorEvent, dispatchFrigateCardErrorEvent,
renderProgressIndicator, renderProgressIndicator,
} from '../components/message.js'; } from '../components/message.js';
import viewerStyle from '../scss/viewer.scss'; import viewerStyle from '../scss/viewer.scss';
import type { import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import {
BrowseMediaNeighbors, BrowseMediaNeighbors,
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
CameraConfig, CameraConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
frigateCardConfigDefaults,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
MediaShowInfo, MediaShowInfo,
TransitionEffect, TransitionEffect,
@@ -39,13 +41,16 @@ import {
overrideMultiBrowseMediaQueryParameters, overrideMultiBrowseMediaQueryParameters,
} from '../utils/ha/browse-media.js'; } from '../utils/ha/browse-media.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { createMediaShowInfo } from '../utils/media-info.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.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 './next-prev-control.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import './title-control.js'; import './title-control.js';
import '../patches/ha-hls-player'; import '../patches/ha-hls-player';
import './surround-thumbnails'; import './surround-thumbnails';
@@ -133,7 +138,7 @@ export class FrigateCardViewer extends LitElement {
const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player'; const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
@customElement('frigate-card-viewer-carousel') @customElement('frigate-card-viewer-carousel')
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: ExtendedHomeAssistant; public hass?: ExtendedHomeAssistant;
@@ -154,6 +159,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
@property({ attribute: false }) @property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache; public resolvedMediaCache?: ResolvedMediaCache;
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
// Mapping of slide # to FrigateBrowseMediaSource child #. // Mapping of slide # to FrigateBrowseMediaSource child #.
// (Folders are not media items that can be rendered). // (Folders are not media items that can be rendered).
protected _slideToChild: Record<number, number> = {}; protected _slideToChild: Record<number, number> = {};
@@ -187,22 +194,20 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* @param changedProperties The properties that were changed in this render. * @param changedProperties The properties that were changed in this render.
*/ */
updated(changedProperties: PropertyValues): void { updated(changedProperties: PropertyValues): void {
if (this._carousel && changedProperties.has('viewerConfig')) { const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel();
this._destroyCarousel();
}
if (this._carousel && changedProperties.has('view')) { if (frigateCardCarousel && changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined; const oldView = changedProperties.get('view') as View | undefined;
if (oldView) { if (oldView) {
if (oldView.target !== this.view?.target) { if (
// If the media target is different entirely, reset the carousel. oldView.target === this.view?.target &&
this._destroyCarousel(); this.view.childIndex != oldView.childIndex
} else if (this.view.childIndex != oldView.childIndex) { ) {
const slide = this._getSlideForChild(this.view.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 // If the media target is the same as already loaded, but isn't of
// the selected slide, scroll to that slide. // 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); 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. * Get the slide number given a media child number.
* @param childIndex The child index (relative to `view.target`) * @param childIndex The child index (relative to `view.target`)
@@ -265,8 +235,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* Get the transition effect to use. * Get the transition effect to use.
* @returns An TransitionEffect object. * @returns An TransitionEffect object.
*/ */
protected _getTransitionEffect(): TransitionEffect | undefined { protected _getTransitionEffect(): TransitionEffect {
return this.viewerConfig?.transition_effect; return (
this.viewerConfig?.transition_effect ??
frigateCardConfigDefaults.media_viewer.transition_effect
);
} }
/** /**
@@ -286,16 +259,16 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
* @param slide An optional slide. * @param slide An optional slide.
* @returns The FrigateCardMediaPlayer or null if not found. * @returns The FrigateCardMediaPlayer or null if not found.
*/ */
protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null { protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
if (this._carousel) { if (!slide) {
if (!slide) { slide = this._refMediaCarousel.value
slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()]; ?.frigateCardCarousel()
} ?.carouselSelectedElement();
return slide?.querySelector(
FRIGATE_CARD_HLS_SELECTOR,
) as FrigateCardMediaPlayer | null;
} }
return null;
return (
(slide?.querySelector(FRIGATE_CARD_HLS_SELECTOR) as FrigateCardMediaPlayer) ?? null
);
} }
/** /**
@@ -304,7 +277,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
*/ */
protected _getPlugins(): EmblaPluginType[] { protected _getPlugins(): EmblaPluginType[] {
return [ return [
...super._getPlugins(),
// Only enable wheel plugin if there is more than one media item. // Only enable wheel plugin if there is more than one media item.
...(this.view && ...(this.view &&
this.view.target && this.view.target &&
@@ -480,15 +452,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
/** /**
* Handle the user selecting a new slide in the carousel. * Handle the user selecting a new slide in the carousel.
*/ */
protected _selectSlideSetViewHandler(): void { protected _setViewHandler(): void {
if (!this._carousel || !this.view) { if (!this._refMediaCarousel.value || !this.view) {
return; return;
} }
// Update the childIndex in the view. // Update the childIndex in the view.
const slidesInView = this._carousel.slidesInView(true); const selected = this._refMediaCarousel.value
if (slidesInView.length) { .frigateCardCarousel()
const childIndex = this._slideToChild[slidesInView[0]]; ?.carouselSelected();
if (selected !== undefined) {
const childIndex = this._slideToChild[selected];
if (childIndex !== undefined) { if (childIndex !== undefined) {
this.view this.view
.evolve({ .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. * Get slides to include in the render.
* @returns The slides to include in the render and an index keyed by slide * @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 { protected _render(): TemplateResult | void {
const [slides, slideToChild] = this._getSlides(); const [slides, slideToChild] = this._getSlides();
this._slideToChild = slideToChild; this._slideToChild = slideToChild;
if (!slides.length) { if (!slides.length || !this.view?.media) {
return; return;
} }
const neighbors = this._getMediaNeighbors(); const neighbors = this._getMediaNeighbors();
const [prev, next] = [neighbors?.previous, neighbors?.next]; const [prev, next] = [neighbors?.previous, neighbors?.next];
return html`<div class="embla"> return html` <frigate-card-media-carousel
<frigate-card-next-previous-control ${ref(this._refMediaCarousel)}
${ref(this._previousControlRef)} .carouselOptions=${this._getOptions()}
.direction=${'previous'} .carouselPlugins=${this._getPlugins()}
.controlConfig=${this.viewerConfig?.controls.next_previous} .autoPlayCondition=${this.viewerConfig?.auto_play}
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined} .autoPauseCondition=${this.viewerConfig?.auto_pause}
.label=${prev ? prev.title : ''} .autoMuteCondition=${this.viewerConfig?.auto_mute}
?disabled=${!prev} .autoUnmuteCondition=${this.viewerConfig?.auto_unmute}
@click=${(ev) => { .label="${this.view.media.title}"
this._nextPreviousHandler('previous'); .titlePopupConfig=${this.viewerConfig?.controls.title}
stopEventFromActivatingCardWideActions(ev); transitionEffect=${this._getTransitionEffect()}
}} @frigate-card:carousel:select=${this._setViewHandler.bind(this)}
></frigate-card-next-previous-control> @frigate-card:media-show=${this._recordingSeekHandler.bind(this)}
<div class="embla__viewport"> >
<div class="embla__container">${slides}</div> <frigate-card-next-previous-control
</div> slot="previous"
<frigate-card-next-previous-control .direction=${'previous'}
${ref(this._nextControlRef)} .controlConfig=${this.viewerConfig?.controls.next_previous}
.direction=${'next'} .thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
.controlConfig=${this.viewerConfig?.controls.next_previous} .label=${prev ? prev.title : ''}
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined} ?disabled=${!prev}
.label=${next ? next.title : ''} @click=${(ev) => {
?disabled=${!next} this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
@click=${(ev) => { stopEventFromActivatingCardWideActions(ev);
this._nextPreviousHandler('next'); }}
stopEventFromActivatingCardWideActions(ev); ></frigate-card-next-previous-control>
}} ${slides}
></frigate-card-next-previous-control> <frigate-card-next-previous-control
</div> slot="next"
${this.view?.media .direction=${'next'}
? html` <frigate-card-title-control .controlConfig=${this.viewerConfig?.controls.next_previous}
${ref(this._titleControlRef)} .thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
.config=${this.viewerConfig?.controls.title} .label=${next ? next.title : ''}
.text="${this.view.media.title}" ?disabled=${!next}
.fitInto=${this as HTMLElement} @click=${(ev) => {
> this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
</frigate-card-title-control>` stopEventFromActivatingCardWideActions(ev);
: ``} `; }}
></frigate-card-next-previous-control>
</frigate-card-media-carousel>`;
} }
/** /**
* Fire a media show event when a slide is selected. * Fire a media show event when a slide is selected.
*/ */
protected _selectSlideMediaShowHandler(): void { protected _recordingSeekHandler(): void {
super._selectSlideMediaShowHandler();
// If this is a recording and play is desired to be started from a // 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 // 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 // -- when the slide is changed, the media show event may be dispatched
@@ -751,8 +700,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
)} )}
.media=${mediaToRender} .media=${mediaToRender}
.hass=${this.hass} .hass=${this.hass}
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => @frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => {
this._mediaShowEventHandler(slideIndex, e)} wrapMediaShowEventForCarousel(slideIndex, e);
}}
> >
</frigate-card-ha-hls-player>` </frigate-card-ha-hls-player>`
: html`<img : html`<img
@@ -762,7 +712,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
)} )}
title="${mediaToRender.title}" title="${mediaToRender.title}"
@click=${() => { @click=${() => {
if (this._carousel?.clickAllowed()) { if (
this._refMediaCarousel.value
?.frigateCardCarousel()
?.carouselClickAllowed()
) {
this._findRelatedClipView(mediaToRender).then((view) => { this._findRelatedClipView(mediaToRender).then((view) => {
if (view) { if (view) {
view.dispatchChangeEvent(this); view.dispatchChangeEvent(this);
@@ -771,6 +725,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
} }
}} }}
@load="${(e: Event) => { @load="${(e: Event) => {
const lazyloadPlugin = this._refMediaCarousel.value
?.frigateCardCarousel()
?.getCarouselPlugins()?.lazyload;
if ( if (
// This handler will be called on the empty image (including // This handler will be called on the empty image (including
// an updated empty image that is the same dimensions large as // 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 // images in media-carousel.ts). Here we need to only call the
// media load handler on a 'real' load. // media load handler on a 'real' load.
!lazyLoad || !lazyLoad ||
this._getLazyLoadPlugin()?.hasLazyloaded(slideIndex) lazyloadPlugin?.hasLazyloaded(slideIndex)
) { ) {
this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); wrapMediaLoadEventForCarousel(slideIndex, e);
} }
}}" }}"
/>`} />`}
</div> </div>
`; `;
} }
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(viewerCarouselStyle);
}
} }
declare global { declare global {
+5 -20
View File
@@ -4,16 +4,9 @@
width: 100%; width: 100%;
} }
img,video {
width: 100%;
height: 100%;
display: block;
}
.embla { .embla {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@@ -28,10 +21,10 @@ img,video {
-khtml-user-select: none; -khtml-user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
:host([direction=vertical]) .embla__container { :host([direction='vertical']) .embla__container {
flex-direction: column; flex-direction: column;
} }
:host([direction=horizontal]) .embla__container { :host([direction='horizontal']) .embla__container {
flex-direction: row; flex-direction: row;
} }
@@ -53,18 +46,10 @@ img,video {
cursor: grabbing; cursor: grabbing;
} }
.embla__slide { :host([direction='vertical']) ::slotted(.embla__slide) {
position: relative;
overflow: visible;
}
:host([direction=vertical]) .embla__slide {
margin-bottom: 5px; margin-bottom: 5px;
} }
:host([direction=horizontal]) .embla__slide {
:host([direction='horizontal']) ::slotted(.embla__slide) {
margin-right: 5px; 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%;
}
+6 -5
View File
@@ -1,8 +1,9 @@
:host { :host {
--video-max-height: none; display: block;
} width: 100%;
.embla__slide {
flex: 0 0 100%;
height: 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 { .controls.icons {
top: calc(50% - (40px / 2)); top: calc(50% - (var(--frigate-card-next-prev-size) / 2));
} }
.controls.thumbnails { .controls.thumbnails {
+4
View File
@@ -1,6 +1,10 @@
@use 'const.scss'; @use 'const.scss';
:host { :host {
display: block;
width: 100%;
height: 100%;
--frigate-card-carousel-thumbnail-opacity: 1; --frigate-card-carousel-thumbnail-opacity: 1;
} }
+4
View File
@@ -3,6 +3,10 @@
overflow: hidden; overflow: hidden;
} }
img {
display: block;
}
img, img,
ha-icon { ha-icon {
border-radius: var(--ha-card-border-radius, 4px); 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" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.161.tgz#49cb5b35385bfee6cc439d0a04fbba7a7a7f08a1"
integrity sha512-sTjBRhqh6wFodzZtc5Iu8/R95OkwaPNn7tj/TaDU5nu/5EFiQDtADGAXdR4tJcTEHlYfJpHqigzJqHvPgehP8A== integrity sha512-sTjBRhqh6wFodzZtc5Iu8/R95OkwaPNn7tj/TaDU5nu/5EFiQDtADGAXdR4tJcTEHlYfJpHqigzJqHvPgehP8A==
embla-carousel-wheel-gestures@^2.1.1: embla-carousel-wheel-gestures@^3.0.0-rc01:
version "2.2.0" version "3.0.0-rc01"
resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-2.2.0.tgz#04ee1cfafe0667a5b96d16341642b3124fe8894e" resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-3.0.0-rc01.tgz#70f88d6ee755817270ca26514d06b7c08c12977d"
integrity sha512-IoRGblg8QWrIgZEW0NbDcIl2fO++BLFf6197k2JNair3pfbyiMYtva6rROgeRiI0sIj2kFwlSEgudTy5f8TzNQ== integrity sha512-h6E1/AwGKEwro8pey6KeOnt/UMvSaCwJKxaA+sz4OERPLmVL06oejVJ2kMjL06y9WaxZ8TqKWR7m0HlbsI9F5A==
dependencies: dependencies:
wheel-gestures "^2.2.5" wheel-gestures "^2.2.5"
embla-carousel@^7.0.0-rc01: embla-carousel@^7.0.0-rc04:
version "7.0.0-rc01" version "7.0.0-rc04"
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc01.tgz#7c9adfd7302b85c2de9354b7ef6343f16a696eb7" resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.0-rc04.tgz#bd9c15da7740660b46232fba720b0c38043b667f"
integrity sha512-IBTSKcPw7u9K0zoLvsnWYsijKsI0msFqzNp6ASIthTXiMZNJNGQt8k0ax7KqUVinDZeNM07WPWqYsjrvUM/Epw== integrity sha512-vhzwCEdEqwS5c6jlfPHy/X1uUkwf9AvMma8KWNbmTgB3NUNN9hoqiyTdrpraMtPU5MzlxpZipln74ZwDK4v63g==
emojis-list@^3.0.0: emojis-list@^3.0.0:
version "3.0.0" version "3.0.0"