{
const item = this._timelineSource?.dataset?.get(id);
return (
+ this._panBehavior !== 'seek-in-camera' ||
+ item?.media?.getCameraID() === this.view?.camera,
item &&
- item.start &&
- item.end &&
- targetTime.getTime() >= item.start &&
- targetTime.getTime() <= item.end
+ item.start &&
+ item.end &&
+ targetTime.getTime() >= item.start &&
+ targetTime.getTime() <= item.end
);
})));
@@ -479,13 +492,30 @@ export class FrigateCardTimelineCore extends LitElement {
}
const canSeek = this._shouldSupportSeeking();
- const newResults =
- this._panBehavior === 'seek-in-media'
- ? null
- : results
- .clone()
- .resetSelectedResult()
- .selectBestResult((media) => findBestMediaIndex(media, targetTime));
+ let newResults: MediaQueriesResults | null = null;
+
+ if (this._panBehavior === 'seek') {
+ newResults = results
+ .clone()
+ .resetSelectedResult()
+ .selectBestResult(
+ (mediaArray) => findBestMediaIndex(mediaArray, targetTime, this.view?.camera),
+ {
+ allCameras: true,
+ main: true,
+ },
+ );
+ } else if (this._panBehavior === 'seek-in-camera') {
+ newResults = results
+ .clone()
+ .resetSelectedResult()
+ .selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), {
+ cameraID: this.view.camera,
+ })
+ .promoteCameraSelectionToMainSelection(this.view.camera);
+ } else if (this._panBehavior === 'seek-in-media') {
+ newResults = results;
+ }
const desiredView: FrigateCardView = this.mini
? targetTime >= new Date()
@@ -493,11 +523,12 @@ export class FrigateCardTimelineCore extends LitElement {
: 'media'
: this.view.view;
+ const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
this.view
.evolve({
+ ...(selectedCamera && { camera: selectedCamera }),
view: desiredView,
- ...(newResults &&
- newResults.hasSelectedResult() && { queryResults: newResults }),
+ queryResults: newResults,
}) // Whether or not to set the timeline window.
.mergeInContext({
...(canSeek && { mediaViewer: { seek: targetTime } }),
@@ -533,6 +564,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this.cameraManager ||
!this.cardWideConfig ||
!timelineCameraIDs ||
+ !this._timelineSource ||
!properties.what
) {
return;
@@ -543,45 +575,10 @@ export class FrigateCardTimelineCore extends LitElement {
if (
this.timelineConfig?.show_recordings &&
- ['background', 'group-label'].includes(properties.what)
+ properties.time &&
+ ['background', 'axis'].includes(properties.what)
) {
- const cameraIDs = properties.group
- ? new Set([String(properties.group)])
- : this._getTimelineCameraIDs();
- const query = cameraIDs
- ? createQueriesForRecordingsView(
- this.cameraManager,
- this.cardWideConfig,
- cameraIDs,
- )
- : null;
- if (query) {
- view = await executeMediaQueryForView(
- this,
- this.hass,
- this.cameraManager,
- this.view,
- query,
- {
- targetView: 'recording',
- targetTime:
- properties.what === 'background'
- ? properties.time
- : this._timeline.getWindow().end,
- select: 'time',
- },
- );
- }
- } else if (this.timelineConfig?.show_recordings && properties.what === 'axis') {
- const query = createQueriesForRecordingsView(
- this.cameraManager,
- this.cardWideConfig,
- timelineCameraIDs,
- {
- start: startOfHour(properties.time),
- end: endOfHour(properties.time),
- },
- );
+ const query = this._createMediaQueries('recording');
if (query) {
view = await executeMediaQueryForView(
this,
@@ -597,10 +594,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:
@@ -610,8 +612,8 @@ export class FrigateCardTimelineCore extends LitElement {
// gallery (i.e. any case where the thumbnails may not be match the
// events on the timeline, e.g. in the snapshots viewer but
// mini-timeline showing all media).
- const fullEventView = await this._createViewWithEventMediaQuery(
- this._createEventMediaQuerys(),
+ const fullEventView = await this._createViewWithMediaQueries(
+ this._createMediaQueries('event'),
{
selectedItem: properties.item,
targetView: 'media',
@@ -673,59 +675,58 @@ export class FrigateCardTimelineCore extends LitElement {
}
this._removeTargetBar();
- if (!this.hass) {
+ if (!this.hass || !this._timeline || !this.view) {
return;
}
- const prefetchedWindow = this._getPrefetchWindow(properties);
- await this._timelineSource?.refresh(this.hass, prefetchedWindow);
+ await this._timelineSource?.refresh(this.hass, this._getPrefetchWindow(properties));
- // Don't show event thumbnails if the user is looking at recordings,
- // as the recording "hours" are the media, not the event
- // clips/snapshots.
- if (
- this._timeline &&
- this.view &&
- !MediaQueriesClassifier.areRecordingQueries(this.view.query)
- ) {
- const newView = await this._createViewWithEventMediaQuery(
- this._createEventMediaQuerys({ window: this._timeline.getWindow() }),
- );
+ const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
+ if (!queryType) {
+ return;
+ }
+ const mediaQuery = this._createMediaQueries(queryType);
+ const newView = await this._createViewWithMediaQueries(mediaQuery);
- // Specifically avoid dispatching new results on range change unless there
- // is something to be gained by doing so. Example usecase: On initial view
- // load in mini timeline mode, the first 50 events are fetched -- the
- // first drag of the timeline should not dispatch new results unless
- // something is actually useful (as otherwise it creates a visible
- // 'flicker' for the user as the viewer reloads all the media).
- const newResults = newView?.queryResults;
- if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) {
- newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
- }
+ // Specifically avoid dispatching new results on range change unless there
+ // is something to be gained by doing so. Example usecase: On initial view
+ // load in mini timeline mode, the first 50 events are fetched -- the
+ // first drag of the timeline should not dispatch new results unless
+ // something is actually useful (as otherwise it creates a visible
+ // 'flicker' for the user as the viewer reloads all the media).
+ const newResults = newView?.queryResults;
+ if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) {
+ newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
}
}
- protected _createEventMediaQuerys(options?: {
- window?: TimelineWindow;
- }): EventMediaQueries | null {
- if (!this._timeline || !this._timelineSource || !this.cardWideConfig) {
+ protected _createMediaQueries(
+ type: MediaQueriesType,
+ options?: {
+ window?: TimelineWindow;
+ },
+ ): MediaQueries | null {
+ if (!this._timeline || !this._timelineSource) {
return null;
}
- const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow(
- options?.window ?? this._timeline.getWindow(),
+ const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
+ this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
);
- const eventQueries =
- this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
- if (!eventQueries) {
- return null;
+ if (type === 'event') {
+ const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
+ return queries ? new EventMediaQueries(queries) : null;
+ } else if (type === 'recording') {
+ const queries =
+ this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow);
+ return queries ? new RecordingMediaQueries(queries) : null;
}
- return new EventMediaQueries(eventQueries);
+ return null;
}
- protected async _createViewWithEventMediaQuery(
- query: EventMediaQueries | null,
+ protected async _createViewWithMediaQueries(
+ query: MediaQueries | null,
options?: {
targetView?: FrigateCardView;
selectedItem?: IdType;
@@ -788,11 +789,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 +822,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,
@@ -889,8 +891,7 @@ export class FrigateCardTimelineCore extends LitElement {
maxItems: this.timelineConfig.clustering_threshold,
clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
- const media = this.view?.queryResults?.getSelectedResult();
- const selectedId = media?.getID();
+ const selectedIDs = this._getAllSelectedMediaIDsFromView();
const firstMedia = (
first).media;
const secondMedia = (second).media;
@@ -900,8 +901,8 @@ export class FrigateCardTimelineCore extends LitElement {
return (
first.type !== 'background' &&
first.type === second.type &&
- first.id !== selectedId &&
- second.id !== selectedId &&
+ !selectedIDs.includes(first.id) &&
+ !selectedIDs.includes(second.id) &&
!!firstMedia &&
!!secondMedia &&
ViewMediaClassifier.isEvent(firstMedia) &&
@@ -956,6 +957,18 @@ export class FrigateCardTimelineCore extends LitElement {
return !!this.hass && !!this.cameraManager;
}
+ protected _getAllSelectedMediaIDsFromView(): IdType[] {
+ return (
+ this.view?.queryResults?.getMultipleSelectedResults({
+ main: true,
+ ...(this.view.isGrid() && { allCameras: true }),
+ }) ?? []
+ )
+ .filter((media) => ViewMediaClassifier.isEvent(media))
+ .map((media) => media.getID())
+ .filter(isTruthy);
+ }
+
/**
* Update the timeline from the view object.
*/
@@ -980,8 +993,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 +1011,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 +1032,27 @@ export class FrigateCardTimelineCore extends LitElement {
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
}
- 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 currentSelection = this._timeline.getSelection();
+ const mediaIDsToSelect = this._getAllSelectedMediaIDsFromView();
- // Need to this rewrite prior to setting the selection (just below), or
- // the selection will be lost on rewrite.
- this._timelineSource?.rewriteEvent(mediaID);
- }
+ const needToSelect = mediaIDsToSelect.some(
+ (mediaID) => !currentSelection.includes(mediaID),
+ );
- const desiredId =
- !!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null;
- if (desiredId) {
- this._timeline?.setSelection([desiredId], {
+ 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.
+
+ for (const mediaID of mediaIDsToSelect) {
+ // 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(mediaIDsToSelect, {
focus: false,
animation: {
animation: false,
@@ -1053,20 +1077,23 @@ export class FrigateCardTimelineCore extends LitElement {
// -> New view received ... [loop]
//
// Also don't generate thumbnails in mini-timelines (they will already have
- // been generated), or if the view is for recordings (media thumbnails are
- // recordings, not events in this case).
+ // been generated).
- const freshMediaQuery = this._createEventMediaQuerys({
+ const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
+ if (!queryType) {
+ return;
+ }
+
+ const freshMediaQuery = this._createMediaQueries(queryType, {
window: desiredWindow,
});
if (
!this.mini &&
- !MediaQueriesClassifier.areRecordingQueries(this.view.query) &&
freshMediaQuery &&
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
) {
- (await this._createViewWithEventMediaQuery(freshMediaQuery))
+ (await this._createViewWithMediaQueries(freshMediaQuery))
?.mergeInContext(this._getTimelineContext(desiredWindow))
.dispatchChangeEvent(this);
}
@@ -1194,8 +1221,6 @@ export class FrigateCardTimelineCore extends LitElement {
createdTimeline = true;
const noGroups = this.mini && groups.length === 1;
if (noGroups) {
- // In a mini timeline, if there's only one group don't bother grouping
- // at all.
this._timeline = new Timeline(
this._refTimeline.value,
this._timelineSource.dataset,
@@ -1244,9 +1269,6 @@ export class FrigateCardTimelineCore extends LitElement {
}
}
- /**
- * Return compiled CSS styles.
- */
static get styles(): CSSResultGroup {
return unsafeCSS(timelineCoreStyle);
}
diff --git a/src/components/timeline.ts b/src/components/timeline.ts
index f70a7db1..4f3cce06 100644
--- a/src/components/timeline.ts
+++ b/src/components/timeline.ts
@@ -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);
}
}
diff --git a/src/components/title-control.ts b/src/components/title-control.ts
index 16f14c9e..d6423a68 100644
--- a/src/components/title-control.ts
+++ b/src/components/title-control.ts
@@ -1,14 +1,52 @@
-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 { Timer } from '../utils/timer';
+import { View } from '../view/view.js';
type PaperToast = HTMLElement & {
opened: boolean;
};
+export const showTitleControlAfterDelay = (
+ control: FrigateCardTitleControl,
+ timer: Timer,
+ delay = 0.5,
+): void => {
+ const show = () => {
+ timer.stop();
+ control.show();
+ };
+
+ if (control.isVisible()) {
+ // If it's already visible, update it immediately (but also update it
+ // after the timer expires to ensure it re-positions if necessary, see
+ // comment below).
+ show();
+ }
+
+ // Allow a brief pause after the media loads, but before the title is
+ // displayed. This allows for a pleasant appearance/disappear of the title,
+ // and allows for the browser to finish rendering the carousel.
+ timer.start(delay, show);
+};
+
+export const getDefaultTitleConfigForView = (
+ view?: Readonly,
+ 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 })
@@ -25,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement {
protected _toastRef: Ref = createRef();
- /**
- * Master render method.
- * @returns A rendered template.
- */
protected render(): TemplateResult {
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
return html``;
@@ -50,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement {
`;
}
- /**
- * Determine if the toast is visible.
- * @returns `true` if the toast is visible, `false` otherwise.
- */
public isVisible(): boolean {
return this._toastRef.value?.opened ?? false;
}
- /**
- * Show the toast.
- */
public hide(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
@@ -68,9 +95,6 @@ export class FrigateCardTitleControl extends LitElement {
}
}
- /**
- * Show the toast.
- */
public show(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
@@ -79,9 +103,6 @@ export class FrigateCardTitleControl extends LitElement {
}
}
- /**
- * Get element styles.
- */
static get styles(): CSSResultGroup {
return unsafeCSS(titleStyle);
}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index 37980d93..a676a3ae 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -1,5 +1,3 @@
-import { EmblaPluginType } from 'embla-carousel';
-import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import {
CSSResultGroup,
html,
@@ -8,13 +6,19 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
-import { customElement, property } from 'lit/decorators.js';
+import { customElement, property, state } 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 { CameraManager } from '../camera-manager/manager.js';
-import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
+import {
+ dispatchMessageEvent,
+ renderMessage,
+ renderProgressIndicator,
+} from '../components/message.js';
import { localize } from '../localize/localize.js';
import '../patches/ha-hls-player';
+import basicBlockStyle from '../scss/basic-block.scss';
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss';
@@ -29,9 +33,19 @@ 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 { CarouselSelected } from '../utils/embla/carousel-controller.js';
+import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
+import { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
+import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
+import AutoSize from '../utils/embla/plugins/auto-size/auto-size.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,
@@ -50,21 +64,21 @@ import {
setControlsOnVideo,
} from '../utils/media.js';
import { screenshotMedia } from '../utils/screenshot.js';
+import { Timer } from '../utils/timer';
import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results.js';
import { VideoContentType, ViewMedia } from '../view/media.js';
import { View } from '../view/view.js';
-import type { CarouselSelect } from './carousel.js';
-import { AutoMediaPlugin } from './embla-plugins/automedia.js';
-import { Lazyload } from './embla-plugins/lazyload.js';
-import {
- FrigateCardMediaCarousel,
- wrapMediaLoadedEventForCarousel,
-} from './media-carousel.js';
+import type { EmblaCarouselPlugins } from './carousel.js';
import './next-prev-control.js';
import './surround.js';
import './title-control.js';
+import {
+ FrigateCardTitleControl,
+ getDefaultTitleConfigForView,
+ showTitleControlAfterDelay,
+} from './title-control.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -76,6 +90,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 })
@@ -96,10 +120,6 @@ export class FrigateCardViewer extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
- /**
- * Master render method.
- * @returns A rendered template.
- */
protected render(): TemplateResult | void {
if (
!this.hass ||
@@ -118,7 +138,14 @@ export class FrigateCardViewer extends LitElement {
// timeline).
const mediaType = this.view.getDefaultMediaType();
if (!mediaType) {
- return;
+ // Directly render an error message (instead of dispatching it upwards)
+ // to preserve the mini-timeline if the user scans into an area with no
+ // media.
+ return renderMessage({
+ type: 'info',
+ message: localize('common.no_media'),
+ icon: 'mdi:multimedia',
+ });
}
if (mediaType === 'recordings') {
@@ -129,8 +156,8 @@ export class FrigateCardViewer extends LitElement {
this.cardWideConfig,
this.view,
{
+ allCameras: this.view.isGrid(),
targetView: 'recording',
- select: 'latest',
},
);
} else {
@@ -141,31 +168,26 @@ export class FrigateCardViewer extends LitElement {
this.cardWideConfig,
this.view,
{
+ allCameras: this.view.isGrid(),
targetView: 'media',
mediaType: mediaType,
- select: 'latest',
},
);
}
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
}
- return html`
-
-
- `;
+ return html`
+ `;
}
- /**
- * Get element styles.
- */
static get styles(): CSSResultGroup {
return unsafeCSS(viewerStyle);
}
@@ -181,6 +203,9 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
public view?: Readonly;
+ @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 +223,13 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
- protected _refMediaCarousel: Ref = createRef();
+ @state()
+ protected _selected = 0;
+
+ protected _media: ViewMedia[] | null = null;
+ protected _titleTimer = new Timer();
+ protected _refTitleControl: Ref = createRef();
+ protected _player: FrigateCardMediaPlayer | null = null;
/**
* The updated lifecycle callback for this element.
@@ -229,47 +260,19 @@ export class FrigateCardViewerCarousel extends LitElement {
);
}
- /**
- * The the HLS player on a slide (or current slide if not provided.)
- * @param slide An optional slide.
- * @returns The FrigateCardMediaPlayer or null if not found.
- */
- protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
- if (!slide) {
- slide = this._refMediaCarousel.value
- ?.frigateCardCarousel()
- ?.getCarouselSelected()?.element;
- }
-
- return (
- (slide?.querySelector(
- FRIGATE_CARD_VIEWER_PROVIDER,
- ) as unknown as FrigateCardMediaPlayer) ?? null
- );
- }
-
/**
* Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes.
*/
- protected _getPlugins(): EmblaPluginType[] {
+ protected _getPlugins(): EmblaCarouselPlugins {
return [
- // Only enable wheel plugin if there is more than one media item.
- ...(this.view?.queryResults?.getResultsCount() ?? 0 > 1
- ? [
- WheelGesturesPlugin({
- // Whether the carousel is vertical or horizontal, interpret y-axis wheel
- // gestures as scrolling for the carousel.
- forceWheelAxis: 'y',
- }),
- ]
- : []),
- Lazyload({
+ AutoLazyLoad({
...(this.viewerConfig?.lazy_load && {
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
}),
}),
- AutoMediaPlugin({
+ AutoMediaLoadedInfo(),
+ AutoMediaActions({
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
...(this.viewerConfig?.auto_play && {
autoPlayCondition: this.viewerConfig.auto_play,
@@ -284,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement {
autoUnmuteCondition: this.viewerConfig.auto_unmute,
}),
}),
+ AutoSize(),
];
}
@@ -292,44 +296,51 @@ 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) {
+ 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];
- }
-
- protected _setViewHandler(ev: CustomEvent): void {
- this._setViewSelectedIndex(ev.detail.index);
+ 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 _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 === 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 +349,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);
}
@@ -354,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement {
'frigate-card-viewer-provider',
) as FrigateCardViewerProvider | null;
if (viewerProvider) {
- viewerProvider.disabled = false;
+ viewerProvider.load = true;
}
}
@@ -363,15 +375,15 @@ 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);
+ const slide = this._renderMediaItem(media);
if (slide) {
slides[i] = slide;
}
@@ -388,11 +400,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 +427,98 @@ 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` ({
- 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)}
- >
- {
- scroll('previous');
- stopEventFromActivatingCardWideActions(ev);
+ const titleConfig = getDefaultTitleConfigForView(
+ this.view,
+ this.viewerConfig?.controls.title,
+ );
+
+ return html`
+ ) => {
+ this._setViewSelectedIndex(ev.detail.index);
}}
- >
- ${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
- {
- scroll('next');
- stopEventFromActivatingCardWideActions(ev);
+ @frigate-card:media:loaded=${(ev: CustomEvent) => {
+ if (this._refTitleControl.value) {
+ showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
+ }
+ this._player = ev.detail.player ?? null;
+ this._seekHandler();
}}
- >
- `;
+ @frigate-card:media:unloaded=${() => {
+ this._player = null;
+ }}
+ >
+ {
+ scroll('previous');
+ stopEventFromActivatingCardWideActions(ev);
+ }}
+ >
+ ${guard(this._media, () => this._getSlides())}
+ {
+ scroll('next');
+ stopEventFromActivatingCardWideActions(ev);
+ }}
+ >
+
+
+
+
+
+ ${cameraMetadata && titleConfig
+ ? html`
+ `
+ : ``}
+ `;
}
/**
@@ -484,26 +526,37 @@ export class FrigateCardViewerCarousel extends LitElement {
*/
protected async _seekHandler(): Promise {
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._player
+ ) {
+ return;
+ }
+ const selectedMedia = this._media[this._selected];
+ if (!selectedMedia) {
return;
}
+ const seekTimeInMedia = selectedMedia.includesTime(seek);
+ setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
+ if (!seekTimeInMedia && !this._player.isPaused()) {
+ this._player.pause();
+ } else if (seekTimeInMedia && this._player.isPaused()) {
+ this._player.play();
+ }
+
const seekTime =
- (await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null;
- const player = this._getPlayer();
- if (player && seekTime !== null) {
- player.seek(seekTime);
+ (await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ??
+ null;
+
+ if (seekTime !== null) {
+ this._player.seek(seekTime);
}
}
- /**
- * Render a single media item in the viewer carousel.
- * @param media The ViewMedia to render.
- * @param index The (slide|queryResult) index of the item to render.
- * @returns A rendered template.
- */
- protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
+ protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
if (!this.hass || !this.view || !this.viewerConfig) {
return null;
}
@@ -516,11 +569,8 @@ export class FrigateCardViewerCarousel extends LitElement {
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
- .disabled=${this.viewerConfig.lazy_load}
+ .load=${!this.viewerConfig.lazy_load}
.cardWideConfig=${this.cardWideConfig}
- @frigate-card:media:loaded=${(e: CustomEvent) => {
- wrapMediaLoadedEventForCarousel(index, e);
- }}
>
`;
}
@@ -530,6 +580,98 @@ 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