Initial support for grid for live and media viewer.
This commit is contained in:
@@ -368,7 +368,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this.view
|
||||
?.evolve({
|
||||
query: newMediaQueries,
|
||||
queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
|
||||
queryResults: new MediaQueriesResults({
|
||||
results: extension.results,
|
||||
}).selectResultIfFound(
|
||||
(media) => media === this.view?.queryResults?.getSelectedResult(),
|
||||
),
|
||||
})
|
||||
@@ -468,7 +470,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this.view
|
||||
.evolve({
|
||||
view: 'media',
|
||||
queryResults: this.view.queryResults?.clone().selectResult(
|
||||
queryResults: this.view.queryResults?.clone().selectIndex(
|
||||
// Media in the gallery is reversed vs the queryResults (see
|
||||
// note above).
|
||||
this._media.length - index - 1,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import liveImageStyle from '../../scss/live-image.scss';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
||||
import '../image.js';
|
||||
import { getStateObjOrDispatchError } from './live.js';
|
||||
@@ -50,7 +50,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return await this._refImage.value?.getScreenshotURL() ?? null;
|
||||
return (await this._refImage.value?.getScreenshotURL()) ?? null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
@@ -77,7 +77,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveImageStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+185
-47
@@ -16,12 +16,12 @@ import { guard } from 'lit/directives/guard.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
|
||||
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||
import liveStyle from '../../scss/live.scss';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
@@ -56,6 +56,9 @@ import '../surround.js';
|
||||
import '../title-control.js';
|
||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
|
||||
import { getDefaultTitleConfigForView } from '../title-control.js';
|
||||
|
||||
interface LiveViewContext {
|
||||
// A cameraID override (used for dependencies/substreams to force a different
|
||||
@@ -126,7 +129,10 @@ export class FrigateCardLive extends LitElement {
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
public nonOverriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public overriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public liveOverrides?: LiveOverrides;
|
||||
@@ -228,13 +234,18 @@ export class FrigateCardLive extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.nonOverriddenLiveConfig ||
|
||||
!this.cameraManager ||
|
||||
!this.view
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Notes:
|
||||
// - See use of liveConfig and not config below -- the carousel will
|
||||
// independently override the liveConfig to reflect the camera in the
|
||||
// - See use of liveConfig and not config below -- the underlying carousel
|
||||
// will independently override the liveConfig to reflect the camera in the
|
||||
// carousel (not necessarily the selected camera).
|
||||
// - Various events are captured to prevent them propagating upwards if the
|
||||
// card is in the background.
|
||||
@@ -244,10 +255,11 @@ export class FrigateCardLive extends LitElement {
|
||||
const result = html`${keyed(
|
||||
this._renderKey,
|
||||
html`
|
||||
<frigate-card-live-carousel
|
||||
<frigate-card-live-grid
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.liveConfig=${this.liveConfig}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||
.inBackground=${this._inBackground}
|
||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||
.liveOverrides=${this.liveOverrides}
|
||||
@@ -276,7 +288,7 @@ export class FrigateCardLive extends LitElement {
|
||||
}
|
||||
}}
|
||||
>
|
||||
</frigate-card-live-carousel>
|
||||
</frigate-card-live-grid>
|
||||
`,
|
||||
)}`;
|
||||
|
||||
@@ -284,16 +296,13 @@ export class FrigateCardLive extends LitElement {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-live-carousel')
|
||||
export class FrigateCardLiveCarousel extends LitElement {
|
||||
@customElement('frigate-card-live-grid')
|
||||
export class FrigateCardLiveGrid extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@@ -301,7 +310,10 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
public nonOverriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public overriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public liveOverrides?: LiveOverrides;
|
||||
@@ -321,6 +333,109 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public microphoneStream?: MediaStream;
|
||||
|
||||
protected _renderCarousel(cameraID?: string): TemplateResult {
|
||||
return html`
|
||||
<frigate-card-live-carousel
|
||||
grid-id=${ifDefined(cameraID)}
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewFilterCameraID=${cameraID}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||
.inBackground=${this.inBackground}
|
||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||
.liveOverrides=${this.liveOverrides}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.microphoneStream=${this.microphoneStream}
|
||||
>
|
||||
</frigate-card-live-carousel>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _gridSelectCamera(cameraID: string, view?: View): void {
|
||||
(view ?? this.view)
|
||||
?.evolve({
|
||||
camera: cameraID,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
protected _needsGrid(): boolean {
|
||||
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
return !!this.view?.isGrid() && !!cameraIDs && cameraIDs.size >= 1;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('view') && this._needsGrid()) {
|
||||
import('../media-grid.js');
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.conditionControllerEpoch || !this.nonOverriddenLiveConfig) {
|
||||
return;
|
||||
}
|
||||
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
if (!this._needsGrid() || !cameraIDs) {
|
||||
return this._renderCarousel();
|
||||
}
|
||||
return html`
|
||||
<frigate-card-media-grid
|
||||
.selected=${this.view?.camera}
|
||||
.displayConfig=${this.overriddenLiveConfig?.display}
|
||||
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
||||
this._gridSelectCamera(ev.detail.selected)}
|
||||
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
|
||||
ev.stopPropagation();
|
||||
this._gridSelectCamera(ev.detail.camera, ev.detail);
|
||||
}}
|
||||
>
|
||||
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
|
||||
</frigate-card-media-grid>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-live-carousel')
|
||||
export class FrigateCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public nonOverriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public overriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public liveOverrides?: LiveOverrides;
|
||||
|
||||
@property({ attribute: false })
|
||||
public inBackground?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public microphoneStream?: MediaStream;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewFilterCameraID?: string;
|
||||
|
||||
// Index between camera name and slide number.
|
||||
protected _cameraToSlide: Record<string, number> = {};
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
@@ -357,7 +472,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
*/
|
||||
protected _getTransitionEffect(): TransitionEffect {
|
||||
return (
|
||||
this.liveConfig?.transition_effect ??
|
||||
this.overriddenLiveConfig?.transition_effect ??
|
||||
frigateCardConfigDefaults.live.transition_effect
|
||||
);
|
||||
}
|
||||
@@ -376,7 +491,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
*/
|
||||
protected _getOptions(): EmblaOptionsType {
|
||||
return {
|
||||
draggable: this.liveConfig?.draggable,
|
||||
// If the carousel is being filtered to a single cameraID, it is never
|
||||
// draggable.
|
||||
draggable: !this.viewFilterCameraID && this.overriddenLiveConfig?.draggable,
|
||||
loop: true,
|
||||
};
|
||||
}
|
||||
@@ -386,10 +503,12 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
* @returns A list of EmblaOptionsTypes.
|
||||
*/
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
const cameraCount = this.viewFilterCameraID
|
||||
? 1
|
||||
: this.cameraManager?.getStore().getVisibleCameraCount() ?? 0;
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one camera.
|
||||
...(cameras && cameras.size > 1
|
||||
...(cameraCount > 1
|
||||
? [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
@@ -399,28 +518,28 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
]
|
||||
: []),
|
||||
Lazyload({
|
||||
...(this.liveConfig?.lazy_load && {
|
||||
...(this.overriddenLiveConfig?.lazy_load && {
|
||||
lazyLoadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||
}),
|
||||
|
||||
lazyUnloadCondition: this.liveConfig?.lazy_unload,
|
||||
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
|
||||
lazyUnloadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||
}),
|
||||
AutoMediaPlugin({
|
||||
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
||||
...(this.liveConfig?.auto_play && {
|
||||
autoPlayCondition: this.liveConfig.auto_play,
|
||||
...(this.overriddenLiveConfig?.auto_play && {
|
||||
autoPlayCondition: this.overriddenLiveConfig.auto_play,
|
||||
}),
|
||||
...(this.liveConfig?.auto_pause && {
|
||||
autoPauseCondition: this.liveConfig.auto_pause,
|
||||
...(this.overriddenLiveConfig?.auto_pause && {
|
||||
autoPauseCondition: this.overriddenLiveConfig.auto_pause,
|
||||
}),
|
||||
...(this.liveConfig?.auto_mute && {
|
||||
autoMuteCondition: this.liveConfig.auto_mute,
|
||||
...(this.overriddenLiveConfig?.auto_mute && {
|
||||
autoMuteCondition: this.overriddenLiveConfig.auto_mute,
|
||||
}),
|
||||
...(this.liveConfig?.auto_unmute && {
|
||||
autoUnmuteCondition: this.liveConfig.auto_unmute,
|
||||
...(this.overriddenLiveConfig?.auto_unmute && {
|
||||
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
@@ -435,7 +554,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
*/
|
||||
protected _getLazyLoadCount(): number | null {
|
||||
// Defaults to fully-lazy loading.
|
||||
return this.liveConfig?.lazy_load === false ? null : 0;
|
||||
return this.overriddenLiveConfig?.lazy_load === false ? null : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,15 +563,25 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
* name to slide number.
|
||||
*/
|
||||
protected _getSlides(): [TemplateResult[], Record<string, number>] {
|
||||
const visibleCameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||
if (!visibleCameras) {
|
||||
let cameras: CameraConfigs | null = null;
|
||||
if (this.viewFilterCameraID) {
|
||||
const config = this.cameraManager
|
||||
?.getStore()
|
||||
.getCameraConfig(this.viewFilterCameraID);
|
||||
if (config) {
|
||||
cameras = new Map([[this.viewFilterCameraID, config]]);
|
||||
}
|
||||
} else {
|
||||
cameras = this.cameraManager?.getStore().getVisibleCameras() ?? null;
|
||||
}
|
||||
if (!cameras) {
|
||||
return [[], {}];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
const cameraToSlide: Record<string, number> = {};
|
||||
|
||||
for (const [cameraID, cameraConfig] of visibleCameras) {
|
||||
for (const [cameraID, cameraConfig] of cameras) {
|
||||
const liveCameraID =
|
||||
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
||||
const liveCameraConfig =
|
||||
@@ -525,7 +654,8 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
if (
|
||||
!this.liveConfig ||
|
||||
!this.overriddenLiveConfig ||
|
||||
!this.nonOverriddenLiveConfig ||
|
||||
!this.hass ||
|
||||
!this.cameraManager ||
|
||||
!this.conditionControllerEpoch
|
||||
@@ -538,7 +668,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
// stateOverride to evaluate the condition in that context.
|
||||
const config = getOverriddenConfig(
|
||||
this.conditionControllerEpoch.controller,
|
||||
this.liveConfig,
|
||||
this.nonOverriddenLiveConfig,
|
||||
this.liveOverrides,
|
||||
{ camera: cameraID },
|
||||
) as LiveConfig;
|
||||
@@ -548,7 +678,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
<frigate-card-live-provider
|
||||
?disabled=${this.liveConfig.lazy_load}
|
||||
?disabled=${config.lazy_load}
|
||||
.microphoneStream=${this.view?.camera === cameraID
|
||||
? this.microphoneStream
|
||||
: undefined}
|
||||
@@ -575,11 +705,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
|
||||
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
|
||||
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||
if (!cameras || !this.view || !this.hass) {
|
||||
if (this.viewFilterCameraID || !cameras || !this.view || !this.hass) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const cameraID = this.viewFilterCameraID ?? this.view.camera;
|
||||
const keys = Array.from(cameras.keys());
|
||||
const currentIndex = keys.indexOf(this.view.camera);
|
||||
const currentIndex = keys.indexOf(cameraID);
|
||||
|
||||
if (currentIndex < 0 || cameras.size <= 1) {
|
||||
return [null, null];
|
||||
@@ -596,7 +728,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) {
|
||||
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -617,12 +749,17 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
: null;
|
||||
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
overrideCameraID(this.view.camera),
|
||||
overrideCameraID(this.viewFilterCameraID ?? this.view.camera),
|
||||
);
|
||||
const cameraMetadataNext = nextID
|
||||
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID))
|
||||
: null;
|
||||
|
||||
const titleConfig = getDefaultTitleConfigForView(
|
||||
this.view,
|
||||
this.overriddenLiveConfig?.controls.title,
|
||||
);
|
||||
|
||||
// Notes on the below:
|
||||
// - guard() is used to avoid reseting the carousel unless the
|
||||
// options/plugins actually change.
|
||||
@@ -637,18 +774,18 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
<frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard(
|
||||
[this.cameraManager, this.liveConfig],
|
||||
[this.cameraManager, this.overriddenLiveConfig],
|
||||
this._getOptions.bind(this),
|
||||
)}
|
||||
.carouselPlugins=${guard(
|
||||
[this.cameraManager, this.liveConfig],
|
||||
[this.cameraManager, this.overriddenLiveConfig],
|
||||
this._getPlugins.bind(this),
|
||||
) as EmblaCarouselPlugins}
|
||||
.label="${cameraMetadataCurrent
|
||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||
: ''}"
|
||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||
.titlePopupConfig=${this.liveConfig.controls.title}
|
||||
.titlePopupConfig=${titleConfig ?? undefined}
|
||||
.selected=${this._getSelectedCameraIndex()}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@@ -661,7 +798,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
|
||||
.label=${cameraMetadataPrevious?.title ?? ''}
|
||||
.icon=${cameraMetadataPrevious?.icon}
|
||||
?disabled=${prevID === null}
|
||||
@@ -676,7 +813,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
|
||||
.label=${cameraMetadataNext?.title ?? ''}
|
||||
.icon=${cameraMetadataNext?.icon}
|
||||
?disabled=${nextID === null}
|
||||
@@ -794,7 +931,7 @@ export class FrigateCardLiveProvider
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
await this.updateComplete;
|
||||
await this._refProvider.value?.updateComplete;
|
||||
return await this._refProvider.value?.getScreenshotURL() ?? null;
|
||||
return (await this._refProvider.value?.getScreenshotURL()) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1002,6 +1139,7 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
|
||||
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
||||
'frigate-card-live-grid': FrigateCardLiveGrid;
|
||||
'frigate-card-live': FrigateCardLive;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,9 +428,6 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
: ``}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(mediaCarouselStyle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// TODO: Performance of video scanning (pause/play?)
|
||||
// TODO: Investigate query spam during a grid load
|
||||
// TODO: Test live pre-load
|
||||
// TODO: Is the query reset in card.ts correct for media filter multi-camera queries that are not all cameras?
|
||||
// TODO: Do I need column max?
|
||||
// TODO: test changing tabs in a dashboard (to trigger disconnect, do I still receive media loads from media that was already loaded)?
|
||||
// TODO: Can SELECT_CHILD_EVENTS only be 'click' and it still work on Android?
|
||||
|
||||
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 mediaGridStyle from '../scss/media-grid.scss';
|
||||
import { ViewDisplayConfig } from '../types.js';
|
||||
import { MediaGridController } from '../utils/media-grid-controller.js';
|
||||
|
||||
@customElement('frigate-card-media-grid')
|
||||
export class FrigateCardMediaGrid extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public selected?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public displayConfig?: ViewDisplayConfig;
|
||||
|
||||
protected _controller: MediaGridController | null = null;
|
||||
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Ensure the controller is recreated.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._controller?.destroy();
|
||||
this._controller = null;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
if (!this._controller && this._refSlot.value) {
|
||||
this._controller = new MediaGridController(this._refSlot.value, {
|
||||
selected: this.selected,
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProps.has('selected')) {
|
||||
if (this.selected) {
|
||||
this._controller?.selectCell(this.selected);
|
||||
} else {
|
||||
this._controller?.unselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has('displayConfig')) {
|
||||
this._controller?.setDisplayConfig(this.displayConfig ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
return html`<slot ${ref(this._refSlot)}></slot> `;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(mediaGridStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-media-grid': FrigateCardMediaGrid;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import surroundStyle from '../scss/surround.scss';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import type { DataQuery } from '../camera-manager/types';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import {
|
||||
CardWideConfig,
|
||||
ClipsOrSnapshotsOrAll,
|
||||
@@ -16,13 +18,11 @@ import {
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
import './surround-basic.js';
|
||||
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
|
||||
import { getAllDependentCameras } from '../utils/camera.js';
|
||||
import type { DataQuery } from '../camera-manager/types';
|
||||
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
|
||||
import { View } from '../view/view.js';
|
||||
import './surround-basic.js';
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
|
||||
interface ThumbnailViewContext {
|
||||
// Whether or not to fetch thumbnails.
|
||||
@@ -88,6 +88,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: this.view.view,
|
||||
mediaType: this.fetchMedia,
|
||||
select: 'latest',
|
||||
@@ -151,10 +152,6 @@ export class FrigateCardSurround extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view) {
|
||||
return;
|
||||
@@ -230,11 +227,8 @@ export class FrigateCardSurround extends LitElement {
|
||||
</frigate-card-surround-basic>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(surroundStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
this,
|
||||
'thumbnail-carousel:tap',
|
||||
{
|
||||
queryResults: this.view.queryResults.clone().selectResult(index),
|
||||
queryResults: this.view.queryResults.clone().selectIndex(index),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -485,7 +485,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
: results
|
||||
.clone()
|
||||
.resetSelectedResult()
|
||||
.selectBestResult((media) => findBestMediaIndex(media, targetTime));
|
||||
.selectBestResult((media) => findBestMediaIndex(media, targetTime), {
|
||||
allCameras: true,
|
||||
main: true,
|
||||
});
|
||||
|
||||
const desiredView: FrigateCardView = this.mini
|
||||
? targetTime >= new Date()
|
||||
@@ -496,8 +499,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
this.view
|
||||
.evolve({
|
||||
view: desiredView,
|
||||
...(newResults &&
|
||||
newResults.hasSelectedResult() && { queryResults: newResults }),
|
||||
queryResults: newResults,
|
||||
}) // Whether or not to set the timeline window.
|
||||
.mergeInContext({
|
||||
...(canSeek && { mediaViewer: { seek: targetTime } }),
|
||||
@@ -597,10 +599,15 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
);
|
||||
}
|
||||
} else if (properties.item && properties.what === 'item') {
|
||||
const cameraID = String(properties.group);
|
||||
const criteria = {
|
||||
main: true,
|
||||
...(cameraID && this.view.isGrid() && { cameraID: cameraID }),
|
||||
};
|
||||
const newResults = this.view.queryResults
|
||||
?.clone()
|
||||
.resetSelectedResult()
|
||||
.selectResultIfFound((media) => media.getID() === properties.item);
|
||||
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
|
||||
|
||||
if (!newResults || !newResults.hasSelectedResult()) {
|
||||
// This can happen in a few situations:
|
||||
@@ -788,11 +795,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
return new DataSet(groups);
|
||||
}
|
||||
|
||||
protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null {
|
||||
const startTime = media.getStartTime();
|
||||
const endTime = media.getEndTime();
|
||||
|
||||
if (ViewMediaClassifier.isEvent(media)) {
|
||||
protected _getPerfectWindowFromMediaStartAndEndTime(
|
||||
isEvent: boolean,
|
||||
startTime: Date | null,
|
||||
endTime: Date | null,
|
||||
): TimelineWindow | null {
|
||||
if (isEvent) {
|
||||
const windowSeconds = this._getConfiguredWindowSeconds();
|
||||
|
||||
if (startTime && endTime) {
|
||||
@@ -820,7 +828,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
end: add(startTime, { seconds: windowSeconds / 2 }),
|
||||
};
|
||||
}
|
||||
} else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) {
|
||||
} else if (startTime && endTime) {
|
||||
return {
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
@@ -980,8 +988,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
let desiredWindow = timelineWindow;
|
||||
const media = this.view.queryResults?.getSelectedResult();
|
||||
const mediaStartTime = media?.getStartTime();
|
||||
const mediaEndTime = media?.getEndTime();
|
||||
const mediaStartTime = media?.getStartTime() ?? null;
|
||||
const mediaEndTime = media?.getEndTime() ?? null;
|
||||
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
|
||||
|
||||
const mediaWindow: TimelineWindow | null =
|
||||
media && mediaStartTime
|
||||
? // If this media has no end time, it's just a "point" in time so the
|
||||
@@ -996,8 +1006,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
if (context && context.window) {
|
||||
desiredWindow = context.window;
|
||||
} else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
|
||||
const perfectMediaWindow = this._getPerfectWindowFromMedia(media);
|
||||
} else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
|
||||
const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime(
|
||||
mediaIsEvent,
|
||||
mediaStartTime,
|
||||
mediaEndTime,
|
||||
);
|
||||
if (perfectMediaWindow) {
|
||||
desiredWindow = perfectMediaWindow;
|
||||
}
|
||||
@@ -1013,22 +1027,22 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
|
||||
}
|
||||
|
||||
const currentSelection = this._timeline.getSelection();
|
||||
const mediaID = media?.getID();
|
||||
if (media && mediaID && this._isClustering()) {
|
||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||
// update the dataset to ensure the newly selected item cannot be included
|
||||
// in a cluster. Only do this when the pointer is not held to avoid
|
||||
// interrupting the user and to make the timeline smoother.
|
||||
const needToSelect = mediaID && mediaIsEvent && !currentSelection.includes(mediaID);
|
||||
|
||||
// Need to this rewrite prior to setting the selection (just below), or
|
||||
// the selection will be lost on rewrite.
|
||||
this._timelineSource?.rewriteEvent(mediaID);
|
||||
}
|
||||
if (needToSelect) {
|
||||
if (this._isClustering()) {
|
||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||
// update the dataset to ensure the newly selected item cannot be included
|
||||
// in a cluster.
|
||||
|
||||
const desiredId =
|
||||
!!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null;
|
||||
if (desiredId) {
|
||||
this._timeline?.setSelection([desiredId], {
|
||||
// Need to this rewrite prior to setting the selection (just below), or
|
||||
// the selection will be lost on rewrite.
|
||||
this._timelineSource?.rewriteEvent(mediaID);
|
||||
}
|
||||
|
||||
this._timeline?.setSelection([mediaID], {
|
||||
focus: false,
|
||||
animation: {
|
||||
animation: false,
|
||||
@@ -1244,9 +1258,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(timelineCoreStyle);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import timelineStyle from '../scss/timeline.scss';
|
||||
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||
import { View } from '../view/view';
|
||||
import './surround.js';
|
||||
import './timeline-core.js';
|
||||
@@ -24,10 +24,6 @@ export class FrigateCardTimeline extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.timelineConfig) {
|
||||
return html``;
|
||||
@@ -49,11 +45,8 @@ export class FrigateCardTimeline extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(timelineStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { TitleControlConfig } from '../types.js';
|
||||
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import titleStyle from '../scss/title-control.scss';
|
||||
import { TitleControlConfig } from '../types.js';
|
||||
import { View } from '../view/view.js';
|
||||
|
||||
type PaperToast = HTMLElement & {
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
export const getDefaultTitleConfigForView = (
|
||||
view?: Readonly<View>,
|
||||
baseConfig?: TitleControlConfig,
|
||||
): TitleControlConfig | null => {
|
||||
if (!baseConfig && view?.isGrid()) {
|
||||
return { mode: 'none', duration_seconds: 2 };
|
||||
}
|
||||
return {
|
||||
mode: 'popup-bottom-right',
|
||||
duration_seconds: 2,
|
||||
...baseConfig,
|
||||
};
|
||||
};
|
||||
|
||||
@customElement('frigate-card-title-control')
|
||||
export class FrigateCardTitleControl extends LitElement {
|
||||
@property({ attribute: false })
|
||||
|
||||
+257
-101
@@ -10,7 +10,9 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
@@ -29,9 +31,14 @@ import {
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { mayHaveAudio } from '../utils/audio.js';
|
||||
import { contentsChanged, errorToConsole } from '../utils/basic.js';
|
||||
import {
|
||||
contentsChanged,
|
||||
errorToConsole,
|
||||
setOrRemoveAttribute,
|
||||
} from '../utils/basic.js';
|
||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||
import { MediaGridSelected } from '../utils/media-grid-controller.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
@@ -65,6 +72,7 @@ import {
|
||||
import './next-prev-control.js';
|
||||
import './surround.js';
|
||||
import './title-control.js';
|
||||
import { getDefaultTitleConfigForView } from './title-control.js';
|
||||
|
||||
export interface MediaViewerViewContext {
|
||||
seek?: Date;
|
||||
@@ -76,6 +84,16 @@ declare module 'view' {
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaNeighbor {
|
||||
index: number;
|
||||
media: ViewMedia;
|
||||
}
|
||||
|
||||
interface MediaNeighbors {
|
||||
previous?: MediaNeighbor;
|
||||
next?: MediaNeighbor;
|
||||
}
|
||||
|
||||
@customElement('frigate-card-viewer')
|
||||
export class FrigateCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
@@ -129,6 +147,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: 'recording',
|
||||
select: 'latest',
|
||||
},
|
||||
@@ -141,6 +160,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: 'media',
|
||||
mediaType: mediaType,
|
||||
select: 'latest',
|
||||
@@ -150,17 +170,15 @@ export class FrigateCardViewer extends LitElement {
|
||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||
}
|
||||
|
||||
return html`
|
||||
<frigate-card-viewer-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
`;
|
||||
return html` <frigate-card-viewer-grid
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer-grid>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +199,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewFilterCameraID?: string;
|
||||
|
||||
// Resetting the viewer configuration causes a full reset so ensure the config
|
||||
// has actually changed with a full comparison (dynamic configuration
|
||||
// overrides may causes changes elsewhere in the full card configuration that
|
||||
@@ -198,7 +219,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selected = 0;
|
||||
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
protected _media: ViewMedia[] | null = null;
|
||||
|
||||
/**
|
||||
* The updated lifecycle callback for this element.
|
||||
@@ -255,7 +280,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one media item.
|
||||
...(this.view?.queryResults?.getResultsCount() ?? 0 > 1
|
||||
...(this._media && this._media.length > 1
|
||||
? [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
@@ -292,20 +317,28 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @returns A BrowseMediaNeighbors with indices and objects of true media
|
||||
* neighbors.
|
||||
*/
|
||||
protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] {
|
||||
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
||||
if (!this.view || !this.view.queryResults || selectedIndex === null) {
|
||||
return [null, null];
|
||||
protected _getMediaNeighbors(): MediaNeighbors | null {
|
||||
const mediaCount = this._media?.length ?? 0;
|
||||
if (!this._media || this.selected === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previous: ViewMedia | null =
|
||||
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null;
|
||||
const next: ViewMedia | null =
|
||||
selectedIndex + 1 < resultCount
|
||||
? this.view.queryResults.getResult(selectedIndex + 1)
|
||||
: null;
|
||||
return [previous, next];
|
||||
const prevIndex = this.selected > 0 ? this.selected - 1 : null;
|
||||
const nextIndex = this.selected + 1 < mediaCount ? this.selected + 1 : null;
|
||||
return {
|
||||
...(prevIndex !== null && {
|
||||
previous: {
|
||||
index: prevIndex,
|
||||
media: this._media[prevIndex],
|
||||
},
|
||||
}),
|
||||
...(nextIndex !== null && {
|
||||
next: {
|
||||
index: nextIndex,
|
||||
media: this._media[nextIndex],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
@@ -313,23 +346,26 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected _setViewSelectedIndex(index: number): void {
|
||||
if (!this.view?.queryResults) {
|
||||
if (!this._media) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = this.view.queryResults.getSelectedIndex();
|
||||
if (selectedIndex === null || selectedIndex === index) {
|
||||
if (this.selected === null || this.selected === index) {
|
||||
// The slide may already be selected on load, so don't dispatch a new view
|
||||
// unless necessary (i.e. the new index is different from the current
|
||||
// index).
|
||||
return;
|
||||
}
|
||||
|
||||
const newResults = this.view?.queryResults?.clone().selectResult(index);
|
||||
const newResults = this.view?.queryResults
|
||||
?.clone()
|
||||
.selectIndex(index, this.viewFilterCameraID);
|
||||
if (!newResults) {
|
||||
return;
|
||||
}
|
||||
const cameraID = newResults.getSelectedResult()?.getCameraID();
|
||||
const cameraID = newResults
|
||||
.getSelectedResult(this.viewFilterCameraID)
|
||||
?.getCameraID();
|
||||
|
||||
this.view
|
||||
?.evolve({
|
||||
@@ -338,6 +374,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
// Always change the camera to the owner of the selected media.
|
||||
...(cameraID && { camera: cameraID }),
|
||||
})
|
||||
.removeContextProperty('mediaViewer', 'seek')
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
@@ -363,13 +400,13 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @returns The slides to include in the render.
|
||||
*/
|
||||
protected _getSlides(): TemplateResult[] {
|
||||
if (!this.view || !this.view.queryResults) {
|
||||
if (!this._media) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
||||
const media = this.view.queryResults.getResult(i);
|
||||
for (let i = 0; i < this._media.length; ++i) {
|
||||
const media = this._media[i];
|
||||
if (media) {
|
||||
const slide = this._renderMediaItem(media, i);
|
||||
if (slide) {
|
||||
@@ -388,11 +425,25 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
if (changedProps.has('viewerConfig')) {
|
||||
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
|
||||
}
|
||||
|
||||
if (changedProps.has('view')) {
|
||||
const newMedia =
|
||||
this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
|
||||
const newSelected =
|
||||
this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
|
||||
const newSeek = this.view?.context?.mediaViewer?.seek;
|
||||
|
||||
if (newMedia !== this._media || newSelected !== this.selected || !newSeek) {
|
||||
setOrRemoveAttribute(this, false, 'unseekable');
|
||||
this._media = newMedia;
|
||||
this.selected = newSelected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
||||
if (!resultCount) {
|
||||
const mediaCount = this._media?.length ?? 0;
|
||||
if (!this._media || !mediaCount) {
|
||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
||||
icon: 'mdi:multimedia',
|
||||
});
|
||||
@@ -401,82 +452,86 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
// If there's no selected media, just choose the last (most recent one) to
|
||||
// avoid rendering a blank. This situation should not occur in practice, as
|
||||
// this view should not be called without a selected media.
|
||||
const media =
|
||||
this.view?.queryResults?.getSelectedResult() ??
|
||||
this.view?.queryResults?.getResult(resultCount - 1);
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameraManager ||
|
||||
!media ||
|
||||
!this.view ||
|
||||
!this.view.queryResults
|
||||
) {
|
||||
const selectedMedia = this._media[this.selected] ?? this._media[mediaCount - 1];
|
||||
|
||||
if (!this.hass || !this.cameraManager || !selectedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [prev, next] = this._getMediaNeighbors();
|
||||
|
||||
const neighbors = this._getMediaNeighbors();
|
||||
const scroll = (direction: 'previous' | 'next'): void => {
|
||||
const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||
if (!this.view || !this.view?.queryResults || currentIndex === null) {
|
||||
if (!neighbors || !this._media) {
|
||||
return;
|
||||
}
|
||||
const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) {
|
||||
const newIndex =
|
||||
(direction === 'previous' ? neighbors.previous?.index : neighbors.next?.index) ??
|
||||
null;
|
||||
if (newIndex !== null) {
|
||||
this._setViewSelectedIndex(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
media.getCameraID(),
|
||||
selectedMedia.getCameraID(),
|
||||
);
|
||||
|
||||
return html` <frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||
draggable: this.viewerConfig?.draggable ?? true,
|
||||
}))}
|
||||
.carouselPlugins=${guard(
|
||||
[this.viewerConfig, this.view.queryResults.getResults()],
|
||||
this._getPlugins.bind(this),
|
||||
)}
|
||||
.label=${media.getTitle() ?? undefined}
|
||||
.logo=${cameraMetadata?.engineLogo}
|
||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
||||
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${prev?.getThumbnail() ?? undefined}
|
||||
.label=${prev?.getTitle() ?? ''}
|
||||
?disabled=${!prev}
|
||||
@click=${(ev) => {
|
||||
scroll('previous');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${next?.getThumbnail() ?? undefined}
|
||||
.label=${next?.getTitle() ?? ''}
|
||||
?disabled=${!next}
|
||||
@click=${(ev) => {
|
||||
scroll('next');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
</frigate-card-media-carousel>`;
|
||||
const titleConfig = getDefaultTitleConfigForView(
|
||||
this.view,
|
||||
this.viewerConfig?.controls.title,
|
||||
);
|
||||
|
||||
return html`
|
||||
<frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||
draggable: this.viewerConfig?.draggable ?? true,
|
||||
}))}
|
||||
.carouselPlugins=${guard(
|
||||
[this.viewerConfig, this._media],
|
||||
this._getPlugins.bind(this),
|
||||
)}
|
||||
.label=${selectedMedia.getTitle() ?? undefined}
|
||||
.logo=${cameraMetadata?.engineLogo}
|
||||
.titlePopupConfig=${titleConfig ?? undefined}
|
||||
.selected=${this.selected ?? 0}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
|
||||
.label=${neighbors?.previous?.media.getTitle() ?? ''}
|
||||
?disabled=${!neighbors?.previous}
|
||||
@click=${(ev: Event) => {
|
||||
scroll('previous');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${guard(this._media, () => this._getSlides())}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
|
||||
.label=${neighbors?.next?.media.getTitle() ?? ''}
|
||||
?disabled=${!neighbors?.next}
|
||||
@click=${(ev: Event) => {
|
||||
scroll('next');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
</frigate-card-media-carousel>
|
||||
<div class="seek-warning">
|
||||
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
|
||||
</ha-icon>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,13 +539,26 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
*/
|
||||
protected async _seekHandler(): Promise<void> {
|
||||
const seek = this.view?.context?.mediaViewer?.seek;
|
||||
const media = this.view?.queryResults?.getSelectedResult();
|
||||
if (!this.hass || !media || !seek) {
|
||||
if (!this.hass || !seek || !this._media || this.selected === null) {
|
||||
return;
|
||||
}
|
||||
const selectedMedia = this._media[this.selected];
|
||||
if (!selectedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
||||
|
||||
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
|
||||
if (!seekTimeInMedia) {
|
||||
this._getPlayer()?.pause();
|
||||
} else {
|
||||
this._getPlayer()?.play();
|
||||
}
|
||||
|
||||
const seekTime =
|
||||
(await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null;
|
||||
(await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ??
|
||||
null;
|
||||
const player = this._getPlayer();
|
||||
if (player && seekTime !== null) {
|
||||
player.seek(seekTime);
|
||||
@@ -530,6 +598,93 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-viewer-grid')
|
||||
export class FrigateCardViewerGrid extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
||||
return html`
|
||||
<frigate-card-viewer-carousel
|
||||
grid-id=${ifDefined(filterCamera)}
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewFilterCameraID=${filterCamera}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _gridSelectCamera(cameraID: string, view?: View): void {
|
||||
const newView = view ?? this.view;
|
||||
const promotedQueryResults = newView?.queryResults
|
||||
?.clone()
|
||||
.promoteCameraSelectionToMainSelection(cameraID);
|
||||
newView
|
||||
?.evolve({
|
||||
camera: cameraID,
|
||||
queryResults: promotedQueryResults,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
changedProps.has('view') &&
|
||||
this.view?.isGrid() &&
|
||||
this.view?.hasMultipleDisplayModes()
|
||||
) {
|
||||
import('./media-grid.js');
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const cameraIDs = this.view?.queryResults?.getCameraIDs();
|
||||
if (!cameraIDs || !this.view?.isGrid() || !this.view?.hasMultipleDisplayModes()) {
|
||||
return this._renderCarousel();
|
||||
}
|
||||
|
||||
return html`
|
||||
<frigate-card-media-grid
|
||||
.selected=${this.view?.camera}
|
||||
.displayConfig=${this.viewerConfig?.display}
|
||||
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
|
||||
this._gridSelectCamera(ev.detail.selected)}
|
||||
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
|
||||
ev.stopPropagation();
|
||||
const childView = ev.detail;
|
||||
this._gridSelectCamera(childView.camera, childView);
|
||||
}}
|
||||
>
|
||||
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
|
||||
</frigate-card-media-grid>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement(FRIGATE_CARD_VIEWER_PROVIDER)
|
||||
export class FrigateCardViewerProvider
|
||||
extends LitElement
|
||||
@@ -679,7 +834,7 @@ export class FrigateCardViewerProvider
|
||||
return;
|
||||
}
|
||||
|
||||
const results = new MediaQueriesResults(mediaArray);
|
||||
const results = new MediaQueriesResults({ results: mediaArray });
|
||||
results.selectResultIfFound(
|
||||
(clipMedia) => clipMedia.getID() === this.media?.getID(),
|
||||
);
|
||||
@@ -830,6 +985,7 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
|
||||
'frigate-card-viewer': FrigateCardViewer;
|
||||
'frigate-card-viewer-grid': FrigateCardViewerGrid;
|
||||
FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,8 @@ export class FrigateCardViews extends LitElement {
|
||||
<frigate-card-live
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.liveConfig=${this.nonOverriddenConfig.live}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
|
||||
.overriddenLiveConfig=${this.config.live}
|
||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
|
||||
.cameraManager=${this.cameraManager}
|
||||
|
||||
Reference in New Issue
Block a user