@@ -0,0 +1,49 @@
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { ViewFolder, ViewItem } from '../../view/item';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { View } from '../../view/view';
|
||||
|
||||
export const upFolderClickHandler = (
|
||||
_item: ViewItem,
|
||||
ev: Event,
|
||||
viewManagerEpoch?: ViewManagerEpoch,
|
||||
): void => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const query = viewManagerEpoch?.manager.getView()?.query;
|
||||
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||
return;
|
||||
}
|
||||
const rawQuery = query?.getQuery();
|
||||
if (!rawQuery?.path || rawQuery?.path.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = rawQuery.path.slice(0, -1);
|
||||
|
||||
viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: query.clone().setQuery({
|
||||
folder: rawQuery.folder,
|
||||
path: [path[0], ...path.slice(1)],
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpFolderMediaItem = (view?: View | null): ViewFolder | null => {
|
||||
const query = view?.query;
|
||||
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawQuery = query.getQuery();
|
||||
if (!rawQuery?.folder || !rawQuery?.path || rawQuery.path.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ViewFolder(rawQuery.folder, {
|
||||
icon: 'mdi:arrow-up-left',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ViewManagerInterface } from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a (folder) thumbnail with details enabled. This is
|
||||
// shorter than for regular camera media as this will consist of just a name.
|
||||
export const FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
|
||||
|
||||
export class FolderGalleryController {
|
||||
private _host: HTMLElement;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
item: ViewItem,
|
||||
ev: Event,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
if (ViewItemClassifier.isMedia(item)) {
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults
|
||||
?.clone()
|
||||
.selectResultIfFound((result) => result === item),
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
ViewItemClassifier.isFolder(item) &&
|
||||
QueryClassifier.isFolderQuery(view.query)
|
||||
) {
|
||||
const rawQuery = view.query.getQuery();
|
||||
const id = item.getID();
|
||||
if (!rawQuery || !id) {
|
||||
return;
|
||||
}
|
||||
viewManager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: view.query.clone().setQuery({
|
||||
folder: rawQuery.folder,
|
||||
path: [...rawQuery.path, { id }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { LitElement, ReactiveController } from 'lit';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { GalleryExtendEvent } from '../../components/gallery/types';
|
||||
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||
import { scrollIntoView } from '../../utils/scroll';
|
||||
import { sleep } from '../../utils/sleep';
|
||||
|
||||
const GALLERY_MIN_EXTENSION_SECONDS = 0.5;
|
||||
|
||||
export type GalleryColumnCountRoundMethod = 'ceil' | 'floor';
|
||||
|
||||
interface GalleryCoreOptions {
|
||||
columnWidth?: number;
|
||||
columnCountRoundMethod?: GalleryColumnCountRoundMethod;
|
||||
extendUp?: boolean;
|
||||
extendDown?: boolean;
|
||||
}
|
||||
|
||||
export class GalleryCoreController implements ReactiveController {
|
||||
private _host: LitElement;
|
||||
private _intersectionObserver: IntersectionObserver;
|
||||
private _resizeObserver: ResizeObserver;
|
||||
|
||||
private _options: GalleryCoreOptions | null = null;
|
||||
private _touchScrollYPosition: number | null = null;
|
||||
|
||||
// Wheel / touch events may be voluminous, throttle extension calls.
|
||||
private _throttledExtendUp = throttle(
|
||||
this._extendUp.bind(this),
|
||||
GALLERY_MIN_EXTENSION_SECONDS * 1000,
|
||||
{
|
||||
leading: true,
|
||||
trailing: false,
|
||||
},
|
||||
);
|
||||
|
||||
private _getSlot: () => HTMLSlotElement | null;
|
||||
private _getSentintelBottom: () => HTMLElement | null;
|
||||
private _showLoaderTop: (show: boolean) => void;
|
||||
private _showSentinelBottom: (show: boolean) => void;
|
||||
|
||||
private _wasEverNonEmpty = false;
|
||||
|
||||
constructor(
|
||||
host: LitElement,
|
||||
getSlot: () => HTMLSlotElement | null,
|
||||
getSentinelBottom: () => HTMLElement | null,
|
||||
showLoaderTopCallback: (show: boolean) => void,
|
||||
showSentinelBottomCallback: (show: boolean) => void,
|
||||
) {
|
||||
this._host = host;
|
||||
this._host.addController(this);
|
||||
|
||||
this._getSlot = getSlot;
|
||||
this._getSentintelBottom = getSentinelBottom;
|
||||
this._showLoaderTop = showLoaderTopCallback;
|
||||
this._showSentinelBottom = showSentinelBottomCallback;
|
||||
|
||||
this._resizeObserver = new ResizeObserver(() => this._setColumnCount());
|
||||
this._intersectionObserver = new IntersectionObserver(
|
||||
async (entries: IntersectionObserverEntry[]): Promise<void> => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
await this._extendDown();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public removeController(): void {
|
||||
this._host.removeController(this);
|
||||
}
|
||||
|
||||
public setOptions(options: GalleryCoreOptions): void {
|
||||
this._options = options;
|
||||
this._setColumnCount();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._resizeObserver.observe(this._host);
|
||||
|
||||
// Since the scroll event does not fire if the user is already at the top of
|
||||
// the container, instead we manually use the wheel and touchstart/end
|
||||
// events to detect "top upwards scrolling" (to trigger an extension of the
|
||||
// gallery).
|
||||
this._host.addEventListener('wheel', this._wheelHandler, { passive: true });
|
||||
this._host.addEventListener('touchstart', this._touchStartHandler, {
|
||||
passive: true,
|
||||
});
|
||||
this._host.addEventListener('touchend', this._touchEndHandler);
|
||||
|
||||
// Request update in order to ensure the intersection observer reconnects
|
||||
// with the loader sentinel.
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._host.removeEventListener('wheel', this._wheelHandler);
|
||||
this._host.removeEventListener('touchstart', this._touchStartHandler);
|
||||
this._host.removeEventListener('touchend', this._touchEndHandler);
|
||||
this._resizeObserver.disconnect();
|
||||
this._intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
const sentinel = this._getSentintelBottom();
|
||||
this._intersectionObserver.disconnect();
|
||||
|
||||
if (sentinel) {
|
||||
this._intersectionObserver.observe(sentinel);
|
||||
}
|
||||
}
|
||||
|
||||
private _setColumnCount(): void {
|
||||
if (!this._options?.columnWidth) {
|
||||
return;
|
||||
}
|
||||
const roundFunc =
|
||||
this._options.columnCountRoundMethod === 'ceil' ? Math.ceil : Math.floor;
|
||||
|
||||
const columns = Math.max(
|
||||
1,
|
||||
roundFunc(this._host.clientWidth / this._options.columnWidth),
|
||||
);
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-gallery-columns',
|
||||
String(columns),
|
||||
);
|
||||
}
|
||||
|
||||
private _touchStartHandler = (ev: TouchEvent): void => {
|
||||
// Remember the Y touch position on touch start, so that we can calculate if
|
||||
// the user gestured upwards or downards on touchend.
|
||||
if (ev.touches.length === 1) {
|
||||
this._touchScrollYPosition = ev.touches[0].screenY;
|
||||
} else {
|
||||
this._touchScrollYPosition = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _touchEndHandler = async (ev: TouchEvent): Promise<void> => {
|
||||
if (
|
||||
!this._host.scrollTop &&
|
||||
ev.changedTouches.length === 1 &&
|
||||
this._touchScrollYPosition !== null
|
||||
) {
|
||||
if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
|
||||
await this._throttledExtendUp();
|
||||
}
|
||||
}
|
||||
this._touchScrollYPosition = null;
|
||||
};
|
||||
|
||||
private _wheelHandler = async (ev: WheelEvent): Promise<void> => {
|
||||
if (!this._host.scrollTop && ev.deltaY < 0) {
|
||||
await this._throttledExtendUp();
|
||||
}
|
||||
};
|
||||
|
||||
private async _extendUp(): Promise<void> {
|
||||
if (!this._options?.extendUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._showLoaderTop(true);
|
||||
|
||||
const start = new Date();
|
||||
await this._waitForExtend('up');
|
||||
const delta = new Date().getTime() - start.getTime();
|
||||
|
||||
if (delta < GALLERY_MIN_EXTENSION_SECONDS * 1000) {
|
||||
// Hidden gem: "legitimate" (?!) use of sleep() :-) These calls can return
|
||||
// very quickly even with caching disabled since the time window
|
||||
// constraints on the query will usually be very narrow and the backend
|
||||
// can thus very quickly reply. It's often so fast it actually looks like
|
||||
// a rendering issue where the progress indictor barely registers before
|
||||
// it's gone again. This optional pause ensures there is at least some
|
||||
// visual feedback to the user that lasts long enough they can 'feel' the
|
||||
// fetch has happened.
|
||||
//
|
||||
// This is only applied on the 'up' extend since the 'down' extend may be
|
||||
// called multiple times for large card sizes (e.g. fullscreen) where a
|
||||
// delay is not desirable.
|
||||
await sleep(GALLERY_MIN_EXTENSION_SECONDS - delta / 1000);
|
||||
}
|
||||
this._showLoaderTop(false);
|
||||
}
|
||||
|
||||
private async _extendDown(): Promise<void> {
|
||||
if (!this._options?.extendDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._showSentinelBottom(false);
|
||||
|
||||
await this._waitForExtend('down');
|
||||
|
||||
// Sentinel will be re-shown next time the contents changes, see:
|
||||
// updateContents() .
|
||||
}
|
||||
|
||||
private async _waitForExtend(direction: 'up' | 'down'): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
fireAdvancedCameraCardEvent<GalleryExtendEvent>(
|
||||
this._host,
|
||||
`gallery:extend:${direction}`,
|
||||
{ resolve },
|
||||
{
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public updateContents(): void {
|
||||
const slot = this._getSlot();
|
||||
|
||||
if (!slot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = slot
|
||||
.assignedElements()
|
||||
.filter((element) => element instanceof HTMLElement);
|
||||
|
||||
const firstSelected = contents.find(
|
||||
(element) => element.getAttribute('selected') !== null,
|
||||
);
|
||||
if (contents.length) {
|
||||
if (!this._wasEverNonEmpty && firstSelected) {
|
||||
// As a special case, if this is the first setting of the slot contents,
|
||||
// the gallery is scrolled to the selected element (if any). This is
|
||||
// only done on the first setting, as subsequent gallery extensions
|
||||
// should not cause the gallery to rescroll to the item that happens to
|
||||
// be selected.
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
||||
scrollIntoView(firstSelected, {
|
||||
boundary: this._host,
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
|
||||
this._wasEverNonEmpty = true;
|
||||
}
|
||||
|
||||
// Always render the bottom sentinel when the contents changes, in order to allow
|
||||
// the gallery to be extended downwards.
|
||||
this._showSentinelBottom(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../../camera-manager/manager';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../../camera-manager/types';
|
||||
import {
|
||||
ViewManagerEpoch,
|
||||
ViewManagerInterface,
|
||||
} from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../../view/query';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { View } from '../../view/view';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
export const MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
export class MediaGalleryController {
|
||||
private _host: HTMLElement;
|
||||
private _media: ViewItem[] | null = null;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public getMedia(): ViewItem[] | null {
|
||||
return this._media;
|
||||
}
|
||||
|
||||
public setMediaFromView(newView?: View | null, oldView?: View | null): void {
|
||||
const newResults = newView?.queryResults?.getResults() ?? null;
|
||||
if (newResults === null) {
|
||||
this._media = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._media || oldView?.queryResults?.getResults() !== newResults) {
|
||||
// Media gallery places the most recent media at the top (the query
|
||||
// results place the most recent media at the end for use in the viewer).
|
||||
// This is copied to a new array to avoid reversing the query results in
|
||||
// place.
|
||||
this._media = [...newResults].reverse();
|
||||
}
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public async extendMediaGallery(
|
||||
cameraManager: CameraManager,
|
||||
viewManagerEpoch: ViewManagerEpoch,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
const view = viewManagerEpoch.manager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = view.query;
|
||||
const existingMedia = view.queryResults?.getResults();
|
||||
if (!existingMedia || !query || !QueryClassifier.isMediaQuery(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawQueries = query.getQuery() ?? null;
|
||||
if (!rawQueries) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
|
||||
try {
|
||||
extension = await cameraManager.extendMediaQueries<MediaQuery>(
|
||||
rawQueries,
|
||||
existingMedia,
|
||||
direction,
|
||||
{
|
||||
useCache: useCache,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
const newMediaQueries = QueryClassifier.isEventQuery(query)
|
||||
? new EventMediaQuery(extension.queries as EventQuery[])
|
||||
: QueryClassifier.isRecordingQuery(query)
|
||||
? new RecordingMediaQuery(extension.queries as RecordingQuery[])
|
||||
: /* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
null;
|
||||
|
||||
/* istanbul ignore else: this path cannot be reached, as we explicitly
|
||||
check for media queries above -- @preserve */
|
||||
if (newMediaQueries) {
|
||||
viewManagerEpoch.manager.setViewByParameters({
|
||||
baseView: view,
|
||||
params: {
|
||||
query: newMediaQueries,
|
||||
queryResults: new QueryResults({
|
||||
results: extension.results,
|
||||
}).selectResultIfFound(
|
||||
(media) => media === view.queryResults?.getSelectedResult(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
reversedIndex: number,
|
||||
ev: Event,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view || !this._media?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults?.clone().selectIndex(
|
||||
// Media in the gallery is reversed vs the queryResults (see
|
||||
// note above).
|
||||
this._media.length - reversedIndex - 1,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, ReactiveController } from 'lit';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { KeyboardShortcut } from '../config/schema/view';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ export const getTechnologyForVideoRTC = (
|
||||
element: VideoRTC,
|
||||
): MediaTechnology[] | undefined => {
|
||||
const tech = [
|
||||
...(!!element.pc ? ['webrtc'] : []),
|
||||
...(!element.pc && element.mseCodecs ? ['mse', 'hls'] : []),
|
||||
...(!!element.pc ? ['webrtc' as const] : []),
|
||||
...(!element.pc && element.mseCodecs ? ['mse' as const, 'hls' as const] : []),
|
||||
];
|
||||
return tech.length ? tech : undefined;
|
||||
};
|
||||
|
||||
@@ -11,19 +11,17 @@ import {
|
||||
sub,
|
||||
} from 'date-fns';
|
||||
import { LitElement } from 'lit';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import { isEqual, orderBy, uniqWith } from 'lodash-es';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { DateRange, PartialDateRange } from '../camera-manager/range';
|
||||
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
|
||||
import { CameraQuery, MediaMetadata, QueryType } from '../camera-manager/types';
|
||||
import { ViewManagerInterface } from '../card-controller/view/types';
|
||||
import { SelectOption, SelectValues } from '../components/select';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
|
||||
interface MediaFilterControls {
|
||||
events: boolean;
|
||||
@@ -216,7 +214,7 @@ export class MediaFilterController {
|
||||
const what = getArrayValueAsSet(values.what);
|
||||
const tags = getArrayValueAsSet(values.tags);
|
||||
|
||||
const queries = new EventMediaQueries([
|
||||
const queries = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
@@ -246,7 +244,7 @@ export class MediaFilterController {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const queries = new RecordingMediaQueries([
|
||||
const queries = new RecordingMediaQuery([
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
@@ -283,9 +281,14 @@ export class MediaFilterController {
|
||||
|
||||
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
||||
const view = this._viewManager?.getView();
|
||||
const queries = view?.query?.getQueries();
|
||||
const query = view?.query;
|
||||
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
||||
if (!view || !queries || !allCameraIDs.size) {
|
||||
if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queries = query.getQuery();
|
||||
if (!queries) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -297,7 +300,7 @@ export class MediaFilterController {
|
||||
let tags: string[] | undefined;
|
||||
|
||||
const cameraIDSets = uniqWith(
|
||||
queries.map((query: DataQuery) => query.cameraIDs),
|
||||
queries.map((query: CameraQuery) => query.cameraIDs),
|
||||
isEqual,
|
||||
);
|
||||
// Special note: If all visible cameras are selected, this is the same as no
|
||||
@@ -317,8 +320,8 @@ export class MediaFilterController {
|
||||
}
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (MediaQueriesClassifier.areEventQueries(view.query)) {
|
||||
const queries = view.query.getQueries();
|
||||
if (QueryClassifier.isEventQuery(view.query)) {
|
||||
const queries = view.query.getQuery();
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
||||
if (!queries) {
|
||||
@@ -362,7 +365,7 @@ export class MediaFilterController {
|
||||
if (tagsSets.length === 1 && queries[0].tags?.size) {
|
||||
tags = [...queries[0].tags];
|
||||
}
|
||||
} else if (MediaQueriesClassifier.areRecordingQueries(view.query)) {
|
||||
} else if (QueryClassifier.isRecordingQuery(view.query)) {
|
||||
mediaType = MediaFilterMediaType.Recordings;
|
||||
}
|
||||
|
||||
@@ -440,8 +443,8 @@ export class MediaFilterController {
|
||||
|
||||
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
|
||||
const view = this._viewManager?.getView();
|
||||
const events = MediaQueriesClassifier.areEventQueries(view?.query);
|
||||
const recordings = MediaQueriesClassifier.areRecordingQueries(view?.query);
|
||||
const events = QueryClassifier.isEventQuery(view?.query);
|
||||
const recordings = QueryClassifier.isRecordingQuery(view?.query);
|
||||
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { isEqual, throttle } from 'lodash-es';
|
||||
import Masonry from 'masonry-layout';
|
||||
import { ViewDisplayConfig } from '../config/schema/common/display';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
|
||||
@@ -36,7 +36,8 @@ export class VideoMediaPlayerController implements MediaPlayerController {
|
||||
await this.mute();
|
||||
try {
|
||||
await video.play();
|
||||
} catch (_) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
// Pass.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StyleInfo } from 'lit/directives/style-map';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager';
|
||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||
@@ -7,12 +8,14 @@ import { ViewManager } from '../card-controller/view/view-manager';
|
||||
import { VIEWS_USER_SPECIFIED } from '../config/schema/common/const';
|
||||
import { MenuItem } from '../config/schema/elements/custom/menu/types';
|
||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||
import { getEntityTitle } from '../ha/get-entity-title';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
import {
|
||||
createCameraAction,
|
||||
createDisplayModeAction,
|
||||
createFolderAction,
|
||||
createGeneralAction,
|
||||
createMediaPlayerAction,
|
||||
createPTZControlsAction,
|
||||
@@ -22,15 +25,17 @@ import {
|
||||
} from '../utils/action';
|
||||
import { arrayify, isTruthy } from '../utils/basic';
|
||||
import { isBeingCasted } from '../utils/casting';
|
||||
import { getEntityTitle } from '../utils/ha';
|
||||
import { getPTZTarget } from '../utils/ptz';
|
||||
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
import { View } from '../view/view';
|
||||
import { getCameraIDsForViewName } from '../view/view-to-cameras';
|
||||
|
||||
export interface MenuButtonControllerOptions {
|
||||
currentMediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
showCameraUIButton?: boolean;
|
||||
foldersManager?: FoldersManager | null;
|
||||
fullscreenManager?: FullscreenManager | null;
|
||||
inExpandedMode?: boolean;
|
||||
microphoneManager?: MicrophoneManager | null;
|
||||
@@ -97,6 +102,7 @@ export class MenuButtonController {
|
||||
this._getDisplayModeButton(config, cameraManager, options?.view),
|
||||
this._getPTZControlsButton(config, cameraManager, options?.view),
|
||||
this._getPTZHomeButton(config, cameraManager, options?.view),
|
||||
this._getFoldersButton(config, options?.foldersManager, options?.view),
|
||||
|
||||
...this._dynamicMenuButtons.map((button) => ({
|
||||
style: this._getStyleFromActions(config, button, options),
|
||||
@@ -330,10 +336,11 @@ export class MenuButtonController {
|
||||
cameraManager: CameraManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const selectedMedia = view?.queryResults?.getSelectedResult();
|
||||
const mediaCapabilities = selectedMedia
|
||||
? cameraManager?.getMediaCapabilities(selectedMedia)
|
||||
: null;
|
||||
const selectedItem = view?.queryResults?.getSelectedResult();
|
||||
const mediaCapabilities =
|
||||
selectedItem && ViewItemClassifier.isMedia(selectedItem)
|
||||
? cameraManager?.getMediaCapabilities(selectedItem)
|
||||
: null;
|
||||
if (view?.isViewerView() && mediaCapabilities?.canDownload && !isBeingCasted()) {
|
||||
return {
|
||||
icon: 'mdi:download',
|
||||
@@ -629,6 +636,58 @@ export class MenuButtonController {
|
||||
};
|
||||
}
|
||||
|
||||
protected _getFoldersButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
foldersManager?: FoldersManager | null,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const folders = [...(foldersManager?.getFolders() ?? [])];
|
||||
if (!folders?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (folders.length === 1) {
|
||||
const isSelected =
|
||||
QueryClassifier.isFolderQuery(view?.query) &&
|
||||
view.query.getQuery()?.folder.id === folders[0][0];
|
||||
const folder = folders[0][1];
|
||||
|
||||
return {
|
||||
icon: folder.icon ?? 'mdi:folder',
|
||||
...config.menu.buttons.folders,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: folder.title ?? localize('config.menu.buttons.folders'),
|
||||
style: isSelected ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFolderAction(),
|
||||
};
|
||||
}
|
||||
|
||||
const submenuItems = folders.map(([id, folder]) => {
|
||||
const isSelected =
|
||||
QueryClassifier.isFolderQuery(view?.query) &&
|
||||
view.query.getQuery()?.folder.id === id;
|
||||
|
||||
const action = createFolderAction({ folderID: id });
|
||||
return {
|
||||
enabled: true,
|
||||
title: folder.title ?? folder.id,
|
||||
icon: folder.icon ?? 'mdi:folder',
|
||||
selected: isSelected,
|
||||
style: isSelected ? this._getEmphasizedStyle() : {},
|
||||
...(action && { tap_action: action }),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
icon: 'mdi:folder-multiple',
|
||||
...config.menu.buttons.folders,
|
||||
type: 'custom:advanced-camera-card-menu-submenu',
|
||||
title: localize('config.menu.buttons.folders'),
|
||||
items: submenuItems,
|
||||
style: view?.is('folder') ? this._getEmphasizedStyle() : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the style of emphasized menu items.
|
||||
* @returns A StyleInfo.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import yaml from 'js-yaml';
|
||||
import { TROUBLESHOOTING_URL } from '../../const';
|
||||
import { Message } from '../../types';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { Message, MessageURL } from '../../types';
|
||||
|
||||
export class MessageController {
|
||||
public getMessageString(message: Message): string {
|
||||
@@ -20,12 +21,12 @@ export class MessageController {
|
||||
: 'mdi:information-outline';
|
||||
}
|
||||
|
||||
public shouldShowTroubleshootingURL(message: Message): boolean {
|
||||
return message.type === 'error';
|
||||
}
|
||||
|
||||
public getTroubleshootingURL(message: Message): string {
|
||||
return message.troubleshootingURL ?? TROUBLESHOOTING_URL;
|
||||
public getURL(message: Message): MessageURL | null {
|
||||
return message.url
|
||||
? message.url
|
||||
: message.type === 'error'
|
||||
? { link: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }
|
||||
: null;
|
||||
}
|
||||
|
||||
public getContextStrings(message: Message): string[] {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LitElement } from 'lit';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { isEqual, orderBy } from 'lodash-es';
|
||||
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request';
|
||||
import { ActionsConfig, StatusBarItem } from '../config/schema/actions/types';
|
||||
import { STATUS_BAR_PRIORITY_DEFAULT } from '../config/schema/common/const';
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { format } from 'date-fns';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { CameraManagerCameraMetadata } from '../../camera-manager/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { Icon } from '../../types';
|
||||
import { getDurationString, prettifyTitle } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
|
||||
interface Detail {
|
||||
icon?: Icon;
|
||||
hint?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export class ThumbnailDetailsController {
|
||||
private _details: Detail[] = [];
|
||||
private _heading: string | null = null;
|
||||
|
||||
public calculate(
|
||||
cameraManager?: CameraManager | null,
|
||||
item?: ViewItem,
|
||||
seek?: Date,
|
||||
): void {
|
||||
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
|
||||
const cameraMetadata = cameraID
|
||||
? cameraManager?.getCameraMetadata(cameraID) ?? null
|
||||
: null;
|
||||
|
||||
this._calculateHeading(cameraMetadata, item);
|
||||
this._calculateDetails(cameraMetadata, item, seek);
|
||||
}
|
||||
|
||||
private _calculateHeading(
|
||||
cameraMetadata: CameraManagerCameraMetadata | null,
|
||||
item?: ViewItem,
|
||||
): void {
|
||||
if (ViewItemClassifier.isEvent(item)) {
|
||||
const what = prettifyTitle(item.getWhat()?.join(', ')) ?? null;
|
||||
const tags = prettifyTitle(item.getTags()?.join(', ')) ?? null;
|
||||
const whatWithTags =
|
||||
what || tags ? (what ?? '') + (what && tags ? ': ' : '') + (tags ?? '') : null;
|
||||
const rawScore = item.getScore();
|
||||
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
|
||||
|
||||
this._heading = whatWithTags ? `${whatWithTags}${score ? ` ${score}` : ''}` : null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cameraMetadata?.title) {
|
||||
this._heading = cameraMetadata.title;
|
||||
return;
|
||||
}
|
||||
|
||||
this._heading = null;
|
||||
}
|
||||
|
||||
private _calculateDetails(
|
||||
cameraMetadata: CameraManagerCameraMetadata | null,
|
||||
item?: ViewItem,
|
||||
seek?: Date,
|
||||
): void {
|
||||
const itemTitle = item?.getTitle() ?? null;
|
||||
|
||||
const startTime = ViewItemClassifier.isMedia(item) ? item.getStartTime() : null;
|
||||
const endTime = ViewItemClassifier.isMedia(item) ? item.getEndTime() : null;
|
||||
const duration = startTime && endTime ? getDurationString(startTime, endTime) : null;
|
||||
const inProgress = ViewItemClassifier.isMedia(item)
|
||||
? item.inProgress()
|
||||
? localize('thumbnail.in_progress')
|
||||
: null
|
||||
: null;
|
||||
const where = ViewItemClassifier.isMedia(item)
|
||||
? prettifyTitle(item?.getWhere()?.join(', ')) ?? null
|
||||
: null;
|
||||
const tags = ViewItemClassifier.isEvent(item)
|
||||
? prettifyTitle(item?.getTags()?.join(', ')) ?? null
|
||||
: null;
|
||||
const seekString = seek ? format(seek, 'HH:mm:ss') : null;
|
||||
|
||||
const details = [
|
||||
...(startTime
|
||||
? [
|
||||
{
|
||||
hint: localize('thumbnail.start'),
|
||||
icon: { icon: 'mdi:calendar-clock-outline' },
|
||||
title: format(startTime, 'yyyy-MM-dd HH:mm:ss'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(duration || inProgress
|
||||
? [
|
||||
{
|
||||
hint: localize('thumbnail.duration'),
|
||||
icon: { icon: 'mdi:clock-outline' },
|
||||
title: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(cameraMetadata?.title
|
||||
? [
|
||||
{
|
||||
hint: localize('thumbnail.camera'),
|
||||
title: cameraMetadata.title,
|
||||
icon: { icon: 'mdi:cctv' },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(where
|
||||
? [
|
||||
{
|
||||
hint: localize('thumbnail.where'),
|
||||
title: where,
|
||||
icon: { icon: 'mdi:map-marker-outline' },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tags
|
||||
? [
|
||||
{
|
||||
hint: localize('thumbnail.tag'),
|
||||
title: tags,
|
||||
icon: { icon: 'mdi:tag' },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(seekString
|
||||
? [
|
||||
{
|
||||
hint: localize('thumbnail.seek'),
|
||||
title: seekString,
|
||||
icon: { icon: 'mdi:clock-fast' },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
this._details = [
|
||||
...(itemTitle
|
||||
? [
|
||||
{
|
||||
title: itemTitle,
|
||||
...(details.length > 0 && {
|
||||
icon: { icon: 'mdi:rename' },
|
||||
hint: localize('thumbnail.title'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...details,
|
||||
];
|
||||
}
|
||||
|
||||
public getHeading(): string | null {
|
||||
return this._heading;
|
||||
}
|
||||
|
||||
public getDetails(): Detail[] {
|
||||
return this._details;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { format } from 'date-fns';
|
||||
import { CameraManager } from '../../../camera-manager/manager';
|
||||
import { CameraManagerCameraMetadata } from '../../../camera-manager/types';
|
||||
import {
|
||||
brandsUrl,
|
||||
extractDomainFromBrandUrl,
|
||||
isBrandUrl,
|
||||
} from '../../../ha/brands-url';
|
||||
import { ViewItem } from '../../../view/item';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
|
||||
export class ThumbnailFeatureController {
|
||||
private _title: string | null = null;
|
||||
private _subtitles: string[] = [];
|
||||
private _icon: string | null = null;
|
||||
private _thumbnail: string | null = null;
|
||||
private _thumbnailClass: string | null = null;
|
||||
|
||||
public calculate(
|
||||
cameraManager?: CameraManager | null,
|
||||
item?: ViewItem,
|
||||
hasDetails?: boolean,
|
||||
): void {
|
||||
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
|
||||
const cameraMetadata = cameraID
|
||||
? cameraManager?.getCameraMetadata(cameraID) ?? null
|
||||
: null;
|
||||
|
||||
this._calculateVisuals(cameraMetadata, item);
|
||||
this._calculateTitles(cameraMetadata, item, hasDetails);
|
||||
}
|
||||
|
||||
private _calculateTitles(
|
||||
cameraMetadata?: CameraManagerCameraMetadata | null,
|
||||
item?: ViewItem,
|
||||
hasDetails?: boolean,
|
||||
) {
|
||||
if (hasDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._thumbnail && ViewItemClassifier.isMedia(item)) {
|
||||
this._title = null;
|
||||
this._subtitles = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime =
|
||||
ViewItemClassifier.isEvent(item) || ViewItemClassifier.isRecording(item)
|
||||
? item.getStartTime()
|
||||
: null;
|
||||
|
||||
this._title = startTime ? format(startTime, 'HH:mm') : null;
|
||||
|
||||
const day = startTime ? format(startTime, 'MMM do') : null;
|
||||
const itemTitle = item?.getTitle() ?? null;
|
||||
const src = cameraMetadata?.title ?? itemTitle ?? null;
|
||||
|
||||
this._subtitles = [...(day ? [day] : []), ...(src ? [src] : [])];
|
||||
}
|
||||
|
||||
private _calculateVisuals(
|
||||
cameraMetadata?: CameraManagerCameraMetadata | null,
|
||||
item?: ViewItem,
|
||||
) {
|
||||
let thumbnail: string | null = item?.getThumbnail() ?? null;
|
||||
if (thumbnail && isBrandUrl(thumbnail)) {
|
||||
thumbnail = brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnail),
|
||||
type: 'icon',
|
||||
useFallback: true,
|
||||
brand: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (thumbnail) {
|
||||
this._thumbnail = thumbnail;
|
||||
this._icon = null;
|
||||
this._thumbnailClass = isBrandUrl(thumbnail) ? 'brand' : null;
|
||||
} else {
|
||||
this._thumbnail = null;
|
||||
this._thumbnailClass = null;
|
||||
this._icon = item?.getIcon() ?? cameraMetadata?.engineIcon ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
public getTitle(): string | null {
|
||||
return this._title;
|
||||
}
|
||||
|
||||
public getSubtitles(): string[] {
|
||||
return this._subtitles;
|
||||
}
|
||||
|
||||
public getIcon(): string | null {
|
||||
return this._icon;
|
||||
}
|
||||
|
||||
public getThumbnail(): string | null {
|
||||
return this._thumbnail;
|
||||
}
|
||||
|
||||
public getThumbnailClass(): string | null {
|
||||
return this._thumbnailClass;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { capEndDate } from '../camera-manager/utils/cap-end-date';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
|
||||
import { ClipsOrSnapshotsOrAll } from '../types';
|
||||
import { errorToConsole, ModifyInterface } from '../utils/basic.js';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { ViewMedia } from '../view/item';
|
||||
|
||||
// Allow timeline freshness to be at least this number of seconds out of date
|
||||
// (caching times in the data-engine may increase the effective delay).
|
||||
@@ -139,10 +139,11 @@ export class TimelineDataSource {
|
||||
for (const media of mediaArray ?? []) {
|
||||
const startTime = media.getStartTime();
|
||||
const id = media.getID();
|
||||
if (id && startTime) {
|
||||
const cameraID = media.getCameraID();
|
||||
if (id && startTime && cameraID) {
|
||||
data.push({
|
||||
id: id,
|
||||
group: media.getCameraID(),
|
||||
group: cameraID,
|
||||
content: '',
|
||||
media: media,
|
||||
start: startTime.getTime(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import round from 'lodash-es/round';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { round, throttle } from 'lodash-es';
|
||||
import { arefloatsApproximatelyEqual, isHoverableDevice } from '../../utils/basic';
|
||||
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||
import {
|
||||
|
||||
@@ -46,15 +46,17 @@ export const handleZoomSettingsObservedEvent = (
|
||||
viewManager?: ViewManagerInterface,
|
||||
targetID?: string,
|
||||
): void => {
|
||||
viewManager &&
|
||||
targetID &&
|
||||
viewManager.setViewByParameters({
|
||||
modifiers: [
|
||||
new MergeContextViewModifier(
|
||||
generateViewContextForZoom(targetID, {
|
||||
observed: ev.detail,
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
if (!viewManager || !targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParameters({
|
||||
modifiers: [
|
||||
new MergeContextViewModifier(
|
||||
generateViewContextForZoom(targetID, {
|
||||
observed: ev.detail,
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user