feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy authored and GitHub committed 2025-05-21 19:59:21 -07:00
1 parent 2eb0d9e35e
commit c6a4c8aea2
350 files changed
+12837 -4509

No files matched your search

+1 -1
View File
@@ -189,7 +189,7 @@ export const actionHandler = directive(
return noChange;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render(_options?: AdvancedCameraCardActionHandlerOptions) {}
},
);
+52
View File
@@ -0,0 +1,52 @@
import { CacheInterface } from './types.js';
export class CacheBase<Key, Value> implements CacheInterface<Key, Value> {
private _cache: Map<Key, Value>;
constructor(cache: Map<Key, Value>) {
this._cache = cache;
}
/**
* Determine if the cache has a given id.
* @param key
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(key: Key): boolean {
return this._cache.has(key);
}
public entries(): MapIterator<[Key, Value]> {
return this._cache.entries();
}
public delete(key: Key): boolean {
return this._cache.delete(key);
}
public clear(): void {
this._cache.clear();
}
/**
* Get resolved media information given an id.
* @param key The id.
* @returns The `ResolvedMedia` for this id.
*/
public get(key: Key): Value | null {
return this._cache.get(key) ?? null;
}
public getMatches(predicate: (arg: Value) => boolean): Value[] {
return [...this._cache.values()].filter(predicate);
}
/**
* Add a given ResolvedMedia to the cache.
* @param key The id for the object.
* @param resolvedMedia The `ResolvedMedia` object.
*/
public set(key: Key, val: Value): void {
this._cache.set(key, val);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { CacheBase } from './base';
export class Cache<Key, Value> extends CacheBase<Key, Value> {
constructor() {
super(new Map());
}
}
+8
View File
@@ -0,0 +1,8 @@
import { CacheBase } from './base';
import { EqualityMap } from './equality-map';
export class EqualityCache<Key, Value> extends CacheBase<Key, Value> {
constructor() {
super(new EqualityMap());
}
}
+80
View File
@@ -0,0 +1,80 @@
import { isEqual } from 'lodash-es';
interface EqualityMapItem<Key, Value> {
key: Key;
value: Value;
}
/** A simple equality based map. This is not performant and should be used for
* small datasets only.
*/
export class EqualityMap<Key, Value> implements Map<Key, Value> {
private _data: EqualityMapItem<Key, Value>[] = [];
get [Symbol.toStringTag](): string {
return 'EqualityMap';
}
public has(key: Key): boolean {
return !!this.get(key);
}
public get(searchKey: Key): Value | undefined {
for (const pair of this._data) {
if (isEqual(pair.key, searchKey)) {
return pair.value;
}
}
return undefined;
}
public set(key: Key, value: Value): this {
this.delete(key);
this._data.push({ key, value });
return this;
}
public delete(searchKey: Key): boolean {
for (let i = 0; i < this._data.length; i++) {
if (isEqual(this._data[i].key, searchKey)) {
this._data.splice(i, 1);
return true;
}
}
return false;
}
public clear(): void {
this._data = [];
}
public *entries(): MapIterator<[Key, Value]> {
for (const pair of this._data) {
yield [pair.key, pair.value];
}
}
public forEach(
callbackfn: (value: Value, key: Key, map: Map<Key, Value>) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thisArg?: any,
): void {
for (const pair of this._data) {
callbackfn.call(thisArg, pair.value, pair.key, this);
}
}
public get size(): number {
return this._data.length;
}
public [Symbol.iterator](): IterableIterator<[Key, Value]> {
return this.entries();
}
public keys(): IterableIterator<Key> {
return this._data.map((pair) => pair.key).values();
}
public values(): IterableIterator<Value> {
return this._data.map((pair) => pair.value).values();
}
}
+70
View File
@@ -0,0 +1,70 @@
import { CacheInterface } from './types';
import { EqualityCache } from './equality-cache';
interface ExpiringValue<Value> {
value: Value;
expires?: Date;
}
export class ExpiringEqualityCache<Key, Value> implements CacheInterface<Key, Value> {
protected _data: EqualityCache<Key, ExpiringValue<Value>> = new EqualityCache();
public get(key: Key): Value | null {
const value = this._data.get(key);
const now = new Date();
return value && (!value.expires || now <= value.expires) ? value.value : null;
}
public has(key: Key): boolean {
return !!this.get(key);
}
public set(key: Key, value: Value, expiry?: Date): void {
this._data.set(key, {
value: value,
expires: expiry,
});
// Clean up old requests on set.
this._expireOldValues();
}
public delete(key: Key): boolean {
return this._data.delete(key);
}
public clear(): void {
this._data.clear();
}
public *entries(): MapIterator<[Key, Value]> {
const now = new Date();
for (const [key, value] of this._data.entries()) {
if (!value.expires || now <= value.expires) {
yield [key, value.value];
}
}
}
public getMatches(predicate: (value: Value) => boolean): Value[] {
const out: Value[] = [];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_key, value] of this.entries()) {
if (predicate(value)) {
out.push(value);
}
}
return out;
}
protected _expireOldValues(): void {
const now = new Date();
for (const [key, value] of this._data.entries()) {
if (value.expires && now > value.expires) {
this._data.delete(key);
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import QuickLRU from 'quick-lru';
import { CacheBase } from './base';
export class LRUCache<Key, Value> extends CacheBase<Key, Value> {
constructor(maxSize: number) {
super(new QuickLRU({ maxSize }));
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface CacheInterface<K, V> {
has(k: K): boolean;
get(k: K): V | null;
set(k: K, v: V): void;
delete(k: K): boolean;
clear(): void;
entries(): MapIterator<[K, V]>;
getMatches(predicate: (arg: V) => boolean): V[];
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera';
import { CameraInitializationError } from '../error';
@@ -1,21 +1,21 @@
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../ha/browse-media/types';
import { BrowseMediaWalker } from '../../ha/browse-media/walker';
import { getMediaDownloadPath } from '../../ha/download';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { canonicalizeHAURL } from '../../utils/ha';
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache';
import { Endpoint } from '../../types';
import { ViewMedia } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { CameraManagerEngine } from '../engine';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEventCallback,
CameraManagerMediaCapabilities,
DataQuery,
CameraManagerRequestCache,
CameraQuery,
EventQuery,
PartialEventQuery,
QueryType,
@@ -28,22 +28,22 @@ export class BrowseMediaCameraManagerEngine
extends GenericCameraManagerEngine
implements CameraManagerEngine
{
protected _browseMediaManager: BrowseMediaManager;
protected _browseMediaWalker: BrowseMediaWalker;
protected _entityRegistryManager: EntityRegistryManager;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _requestCache: RequestCache;
protected _requestCache: CameraManagerRequestCache;
public constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
browseMediaManager: BrowseMediaManager,
browseMediaManager: BrowseMediaWalker,
resolvedMediaCache: ResolvedMediaCache,
requestCache: RequestCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._browseMediaManager = browseMediaManager;
this._browseMediaWalker = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
this._requestCache = requestCache;
}
@@ -66,18 +66,11 @@ export class BrowseMediaCameraManagerEngine
hass: HomeAssistant,
_cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
const contentID = media.getContentID();
if (!contentID) {
return null;
}
const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache);
return resolvedMedia
? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) }
: null;
): Promise<Endpoint | null> {
return getMediaDownloadPath(hass, media.getContentID(), this._resolvedMediaCache);
}
public getQueryResultMaxAge(query: DataQuery): number | null {
public getQueryResultMaxAge(query: CameraQuery): number | null {
if (query.type === QueryType.Event) {
return BROWSE_MEDIA_CACHE_SECONDS;
}
@@ -85,7 +78,7 @@ export class BrowseMediaCameraManagerEngine
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities {
return {
canFavorite: false,
canDownload: true,
-85
View File
@@ -1,85 +0,0 @@
import { format } from 'date-fns';
import isEqual from 'lodash-es/isEqual';
import { formatDateAndTime } from '../../utils/basic';
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
import {
ViewMedia,
EventViewMedia,
ViewMediaType,
VideoContentType,
} from '../../view/media';
import { BrowseMediaMetadata } from '../browse-media/types';
class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
protected _browseMedia: RichBrowseMedia<BrowseMediaMetadata>;
protected _id: string;
constructor(
mediaType: ViewMediaType,
cameraID: string,
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
) {
super(mediaType, cameraID);
this._browseMedia = browseMedia;
// Generate a custom ID that uses the start date (to allow multiple
// BrowseMedia objects (e.g. images and movies) to be de-duplicated).
if (browseMedia._metadata?.startDate) {
this._id = `${cameraID}/${format(
browseMedia._metadata.startDate,
'yyyy-MM-dd HH:mm:ss',
)}`;
} else {
this._id = browseMedia.media_content_id;
}
}
public getStartTime(): Date | null {
return this._browseMedia._metadata?.startDate ?? null;
}
public getEndTime(): Date | null {
return this._browseMedia._metadata?.endDate ?? null;
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.MP4;
}
public getID(): string {
return this._id;
}
public getContentID(): string {
return this._browseMedia.media_content_id;
}
public getTitle(): string | null {
const startTime = this.getStartTime();
return startTime ? formatDateAndTime(startTime) : this._browseMedia.title;
}
public getThumbnail(): string | null {
return this._browseMedia.thumbnail;
}
public getWhat(): string[] | null {
return this._browseMedia._metadata?.what ?? null;
}
public getScore(): number | null {
return null;
}
public getTags(): string[] | null {
return null;
}
public isGroupableWith(that: EventViewMedia): boolean {
return (
this.getMediaType() === that.getMediaType() &&
isEqual(this.getWhere(), that.getWhere()) &&
isEqual(this.getWhat(), that.getWhat())
);
}
}
export class BrowseMediaViewMediaFactory {
static createEventViewMedia(
mediaType: 'clip' | 'snapshot',
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
cameraID: string,
): BrowseMediaEventViewMedia {
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
}
}
-6
View File
@@ -1,6 +0,0 @@
export interface BrowseMediaMetadata {
cameraID: string;
startDate: Date;
endDate: Date;
what?: string[];
}
@@ -1,49 +0,0 @@
import {
RichBrowseMedia,
MEDIA_CLASS_VIDEO,
MEDIA_CLASS_IMAGE,
} from '../../../utils/ha/browse-media/types';
import { ViewMedia } from '../../../view/media';
import { BrowseMediaViewMediaFactory } from '../media';
import { BrowseMediaMetadata } from '../types';
export const getViewMediaFromBrowseMediaArray = (
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
): ViewMedia[] | null => {
const lookup: Map<string, ViewMedia> = new Map();
for (const browseMediaItem of browseMedia) {
const cameraID = browseMediaItem._metadata?.cameraID;
if (!cameraID) {
continue;
}
const mediaType =
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
? 'clip'
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
? 'snapshot'
: null;
if (!mediaType) {
continue;
}
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
mediaType,
browseMediaItem,
cameraID,
);
const id = media.getID();
const existing = lookup.get(id);
// De-duplicate events with precisely the same ID (same
// hour/minute/second) choosing clip > snapshot.
if (
!existing ||
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
) {
lookup.set(id, media);
}
}
return [...lookup.values()];
};
+2 -65
View File
@@ -1,69 +1,6 @@
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import sortedUniqBy from 'lodash-es/sortedUniqBy';
import { orderBy, sortedUniqBy } from 'lodash-es';
import { DateRange, MemoryRangeSet } from './range';
import { DataQuery, QueryResults, RecordingSegment } from './types';
interface RequestCacheItem<Request, Response> {
request: Request;
response: Response;
expires?: Date;
}
interface CameraManagerCache<Request, Response> {
get(request: Request): Response | null;
has(request: Request): boolean;
set(request: Request, response: Response, expiry?: Date): void;
}
export class MemoryRequestCache<Request, Response>
implements CameraManagerCache<Request, Response>
{
protected _data: RequestCacheItem<Request, Response>[] = [];
public get(request: Request): Response | null {
const now = new Date();
for (const item of this._data) {
if (
(!item.expires || now <= item.expires) &&
this._contains(request, item.request)
) {
return item.response;
}
}
return null;
}
public clear(): void {
this._data = [];
}
public has(request: Request): boolean {
return !!this.get(request);
}
public set(request: Request, response: Response, expiry?: Date): void {
this._data.push({
request: request,
response: response,
expires: expiry,
});
// Clean up old requests on set.
this._expireOldRequests();
}
protected _contains(a: Request, b: Request): boolean {
return isEqual(a, b);
}
protected _expireOldRequests(): void {
const now = new Date();
this._data = this._data.filter((item) => !item.expires || now < item.expires);
}
}
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
import { RecordingSegment } from './types';
class MemoryRangedCache<Data> {
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
+2 -1
View File
@@ -2,8 +2,9 @@ import { ActionsExecutor } from '../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras';
import { isTriggeredState } from '../ha/is-triggered-state';
import { HassStateDifference } from '../ha/types';
import { localize } from '../localize/localize';
import { HassStateDifference, isTriggeredState } from '../utils/ha';
import { Capabilities } from './capabilities';
import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
+10 -10
View File
@@ -1,14 +1,14 @@
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { CameraConfig } from '../config/schema/cameras';
import { BrowseMediaWalker } from '../ha/browse-media/walker';
import { EntityRegistryManager } from '../ha/registry/entity/types';
import { ResolvedMediaCache } from '../ha/resolved-media';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { RecordingSegmentsCache, RequestCache } from './cache';
import { RecordingSegmentsCache } from './cache';
import { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error';
import { CameraEventCallback, Engine } from './types';
import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types';
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
interface CameraManagerEngineFactoryOptions {
@@ -44,7 +44,7 @@ export class CameraManagerEngineFactory {
this._entityRegistryManager,
options.stateWatcher,
new RecordingSegmentsCache(),
new RequestCache(),
new CameraManagerRequestCache(),
options.eventCallback,
);
break;
@@ -55,9 +55,9 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new MotionEyeCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
new BrowseMediaManager(),
new BrowseMediaWalker(),
options.resolvedMediaCache,
new RequestCache(),
new CameraManagerRequestCache(),
options.eventCallback,
);
break;
@@ -66,9 +66,9 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new ReolinkCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
new BrowseMediaManager(),
new BrowseMediaWalker(),
options.resolvedMediaCache,
new RequestCache(),
new CameraManagerRequestCache(),
options.eventCallback,
);
}
+7 -7
View File
@@ -1,15 +1,15 @@
import { CameraConfig } from '../config/schema/cameras';
import { HomeAssistant } from '../ha/types';
import { ViewMedia } from '../view/media';
import { Endpoint } from '../types';
import { ViewMedia } from '../view/item';
import { ViewItemCapabilities } from '../view/types';
import { Camera } from './camera';
import { CameraManagerReadOnlyConfigStore } from './store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -90,7 +90,7 @@ export interface CameraManagerEngine {
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null>;
): Promise<Endpoint | null>;
favoriteMedia(
hass: HomeAssistant,
@@ -99,7 +99,7 @@ export interface CameraManagerEngine {
favorite: boolean,
): Promise<void>;
getQueryResultMaxAge(query: DataQuery): number | null;
getQueryResultMaxAge(query: CameraQuery): number | null;
getMediaSeekTime(
hass: HomeAssistant,
@@ -121,7 +121,7 @@ export interface CameraManagerEngine {
cameraConfig: CameraConfig,
): CameraManagerCameraMetadata;
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null;
getCameraEndpoints(
cameraConfig: CameraConfig,
+2 -2
View File
@@ -1,13 +1,13 @@
import uniq from 'lodash-es/uniq';
import { uniq } from 'lodash-es';
import { ActionsExecutor } from '../../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras';
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine';
+27 -28
View File
@@ -1,22 +1,21 @@
import { add, endOfHour, format, fromUnixTime, startOfHour } from 'date-fns';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniqWith from 'lodash-es/uniqWith';
import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import {
allPromises,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
} from '../../utils/basic';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache';
import { ViewMedia, ViewMediaType } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
import { RecordingSegmentsCache } from '../cache';
import { Camera } from '../camera';
import {
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
@@ -26,13 +25,12 @@ import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { DateRange } from '../range';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraManagerRequestCache,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -112,7 +110,7 @@ export class FrigateCameraManagerEngine
protected _entityRegistryManager: EntityRegistryManager;
protected _frigateEventWatcher: FrigateEventWatcher;
protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: RequestCache;
protected _requestCache: CameraManagerRequestCache;
// Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle(
@@ -125,7 +123,7 @@ export class FrigateCameraManagerEngine
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventCallback);
@@ -158,13 +156,13 @@ export class FrigateCameraManagerEngine
_hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
): Promise<Endpoint | null> {
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/notifications/${media.getID()}/` +
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`${ViewItemClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`,
sign: true,
};
@@ -541,7 +539,7 @@ export class FrigateCameraManagerEngine
protected _getCameraIDMatch(
store: CameraManagerReadOnlyConfigStore,
query: DataQuery,
query: CameraQuery,
instanceID: string,
cameraName: string,
): string | null {
@@ -588,17 +586,17 @@ export class FrigateCameraManagerEngine
if (!cameraConfig) {
continue;
}
let mediaType: 'clip' | 'snapshot' | null = null;
let mediaType: ViewMediaType | null = null;
if (
!query.hasClip &&
!query.hasSnapshot &&
(event.has_clip || event.has_snapshot)
) {
mediaType = event.has_clip ? 'clip' : 'snapshot';
mediaType = event.has_clip ? ViewMediaType.Clip : ViewMediaType.Snapshot;
} else if (query.hasSnapshot && event.has_snapshot) {
mediaType = 'snapshot';
mediaType = ViewMediaType.Snapshot;
} else if (query.hasClip && event.has_clip) {
mediaType = 'clip';
mediaType = ViewMediaType.Clip;
}
if (!mediaType) {
continue;
@@ -646,7 +644,7 @@ export class FrigateCameraManagerEngine
return output;
}
public getQueryResultMaxAge(query: DataQuery): number | null {
public getQueryResultMaxAge(query: CameraQuery): number | null {
if (query.type === QueryType.Event) {
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (query.type === QueryType.Recording) {
@@ -664,11 +662,12 @@ export class FrigateCameraManagerEngine
): Promise<number | null> {
const start = media.getStartTime();
const end = media.getEndTime();
if (!start || !end || target < start || target > end) {
const cameraID = media.getCameraID();
if (!start || !end || target < start || target > end || !cameraID) {
return null;
}
const cameraID = media.getCameraID();
const query: RecordingSegmentsQuery = {
cameraIDs: new Set([cameraID]),
start: start,
@@ -898,9 +897,9 @@ export class FrigateCameraManagerEngine
return seekMilliseconds / 1000;
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
public getMediaCapabilities(media: ViewMedia): ViewItemCapabilities {
return {
canFavorite: ViewMediaClassifier.isEvent(media),
canFavorite: ViewItemClassifier.isEvent(media),
canDownload: true,
};
}
@@ -926,7 +925,7 @@ export class FrigateCameraManagerEngine
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
const getUIEndpoint = (): Endpoint | null => {
if (!cameraConfig.frigate.url) {
return null;
}
@@ -976,7 +975,7 @@ export class FrigateCameraManagerEngine
};
};
const getJSMPEG = (): CameraEndpoint | null => {
const getJSMPEG = (): Endpoint | null => {
return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` +
@@ -53,6 +53,7 @@ export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInter
let json: unknown;
try {
json = JSON.parse(data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.warn('Received non-JSON payload as Frigate event', data);
return;
@@ -1,4 +1,4 @@
import { ViewMedia } from '../../view/media';
import { ViewMedia } from '../../view/item';
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
export class FrigateViewMediaClassifier {
+6 -8
View File
@@ -1,5 +1,5 @@
import { fromUnixTime } from 'date-fns';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import {
EventViewMedia,
@@ -7,7 +7,7 @@ import {
VideoContentType,
ViewMedia,
ViewMediaType,
} from '../../view/media';
} from '../../view/item';
import { FrigateEvent, FrigateRecording } from './types';
import {
getEventMediaContentID,
@@ -36,7 +36,7 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
// sublabels (`_splitSubLabels` in engine-frigate.ts).
subLabels?: string[],
) {
super(mediaType, cameraID);
super(mediaType, { cameraID });
this._event = event;
this._contentID = contentID;
this._thumbnail = thumbnail;
@@ -88,8 +88,6 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
public getTags(): string[] | null {
return this._subLabels;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public isGroupableWith(that: EventViewMedia): boolean {
return (
this.getMediaType() === that.getMediaType() &&
@@ -113,7 +111,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
contentID: string,
title: string,
) {
super(mediaType, cameraID);
super(mediaType, { cameraID });
this._recording = recording;
this._id = id;
this._contentID = contentID;
@@ -150,7 +148,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
export class FrigateViewMediaFactory {
static createEventViewMedia(
mediaType: 'clip' | 'snapshot',
mediaType: ViewMediaType,
cameraID: string,
cameraConfig: CameraConfig,
event: FrigateEvent,
@@ -191,7 +189,7 @@ export class FrigateViewMediaFactory {
}
return new FrigateRecordingViewMedia(
'recording',
ViewMediaType.Recording,
cameraID,
recording,
getRecordingID(cameraConfig, recording),
+1 -1
View File
@@ -1,7 +1,7 @@
import { HomeAssistant } from '../../ha/types';
import { homeAssistantWSRequest } from '../../ha/ws-request';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
import { homeAssistantWSRequest } from '../../utils/ha/ws-request';
import { RecordingSegment } from '../types';
import {
EventSummary,
+9 -10
View File
@@ -1,23 +1,22 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { HomeAssistant } from '../../ha/types';
import { getEntityTitle } from '../../utils/ha';
import { ViewMedia } from '../../view/media';
import { Endpoint } from '../../types';
import { ViewMedia } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -155,7 +154,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
): Promise<CameraEndpoint | null> {
): Promise<Endpoint | null> {
return null;
}
@@ -168,7 +167,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return;
}
public getQueryResultMaxAge(_query: DataQuery): number | null {
public getQueryResultMaxAge(_query: CameraQuery): number | null {
return null;
}
@@ -211,7 +210,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
};
}
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities | null {
return null;
}
@@ -219,7 +218,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
cameraConfig: CameraConfig,
_context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getWebRTCCard = (): CameraEndpoint | null => {
const getWebRTCCard = (): Endpoint | null => {
// The user may override this in their webrtc_card configuration.
const endpoint = cameraConfig.camera_entity ? cameraConfig.camera_entity : null;
return endpoint ? { endpoint: endpoint } : null;
+37 -32
View File
@@ -1,12 +1,13 @@
import { add } from 'date-fns';
import cloneDeep from 'lodash-es/cloneDeep';
import sum from 'lodash-es/sum';
import { cloneDeep, sum } from 'lodash-es';
import PQueue from 'p-queue';
import { CardCameraAPI } from '../card-controller/types.js';
import { sortItems } from '../card-controller/view/sort.js';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz.js';
import { CameraConfig, CamerasConfig } from '../config/schema/cameras.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { localize } from '../localize/localize.js';
import { Endpoint } from '../types.js';
import {
allPromises,
arrayify,
@@ -16,19 +17,19 @@ import {
} from '../utils/basic.js';
import { getCameraID } from '../utils/camera.js';
import { log } from '../utils/debug.js';
import { ViewMedia } from '../view/media.js';
import { ViewItemClassifier } from '../view/item-classifier.js';
import { ViewItem, ViewMedia } from '../view/item.js';
import { ViewItemCapabilities } from '../view/types.js';
import { Capabilities } from './capabilities.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { CameraManagerEngine } from './engine.js';
import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -38,7 +39,7 @@ import {
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaQuery,
PartialDataQuery,
PartialCameraQuery,
PartialEventQuery,
PartialQueryConcreteType,
PartialRecordingQuery,
@@ -55,24 +56,25 @@ import {
RecordingSegmentsQueryResultsMap,
ResultsMap,
} from './types.js';
import { sortMedia } from './utils/sort-media.js';
export class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
export class CameraQueryClassifier {
public static isEventQuery(
query: CameraQuery | PartialCameraQuery,
): query is EventQuery {
return query.type === QueryType.Event;
}
public static isRecordingQuery(
query: DataQuery | PartialDataQuery,
query: CameraQuery | PartialCameraQuery,
): query is RecordingQuery {
return query.type === QueryType.Recording;
}
public static isRecordingSegmentsQuery(
query: DataQuery | PartialDataQuery,
query: CameraQuery | PartialCameraQuery,
): query is RecordingSegmentsQuery {
return query.type === QueryType.RecordingSegments;
}
public static isMediaMetadataQuery(
query: DataQuery | PartialDataQuery,
query: CameraQuery | PartialCameraQuery,
): query is MediaMetadataQuery {
return query.type === QueryType.MediaMetadata;
}
@@ -103,7 +105,7 @@ export class QueryResultClassifier {
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewMedia[];
results: ViewItem[];
}
export class CameraManager {
@@ -318,7 +320,7 @@ export class CameraManager {
});
}
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
cameraIDs: string | Set<string>,
partialQuery: PQT,
): PartialQueryConcreteType<PQT>[] | null {
@@ -330,17 +332,17 @@ export class CameraManager {
}
for (const [engine, cameraIDs] of engines) {
let queries: DataQuery[] | null = null;
let queries: CameraQuery[] | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (QueryClassifier.isEventQuery(partialQuery)) {
if (CameraQueryClassifier.isEventQuery(partialQuery)) {
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
} else if (CameraQueryClassifier.isRecordingQuery(partialQuery)) {
queries = engine.generateDefaultRecordingQuery(
this._store,
cameraIDs,
partialQuery,
);
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
queries = engine.generateDefaultRecordingSegmentsQuery(
this._store,
cameraIDs,
@@ -426,7 +428,7 @@ export class CameraManager {
public async extendMediaQueries<T extends MediaQuery>(
queries: T[],
results: ViewMedia[],
results: ViewItem[],
direction: 'earlier' | 'later',
engineOptions?: EngineOptions,
): Promise<ExtendedMediaQueryResult<T> | null> {
@@ -438,6 +440,9 @@ export class CameraManager {
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
let output: Date | null = null;
for (const result of results) {
if (!ViewItemClassifier.isMedia(result)) {
continue;
}
const startTime = result.getStartTime();
if (
startTime &&
@@ -495,7 +500,7 @@ export class CameraManager {
return null;
}
const outputMedia = sortMedia(results.concat(newChunkMedia));
const outputMedia = sortItems(results.concat(newChunkMedia));
// If the media did not _ACTUALLY_ get longer, there is no new media despite
// the increased limit, so just return null.
@@ -509,7 +514,7 @@ export class CameraManager {
};
}
public async getMediaDownloadPath(media: ViewMedia): Promise<CameraEndpoint | null> {
public async getMediaDownloadPath(media: ViewMedia): Promise<Endpoint | null> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
@@ -520,7 +525,7 @@ export class CameraManager {
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
public getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null {
const engine = this._store.getEngineForMedia(media);
if (!engine) {
return null;
@@ -603,7 +608,7 @@ export class CameraManager {
);
}
protected async _handleQuery<QT extends DataQuery>(
protected async _handleQuery<QT extends CameraQuery>(
query: QT | QT[],
engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> {
@@ -623,28 +628,28 @@ export class CameraManager {
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (QueryClassifier.isEventQuery(query)) {
if (CameraQueryClassifier.isEventQuery(query)) {
engineResult = (await engine.getEvents(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingQuery(query)) {
} else if (CameraQueryClassifier.isRecordingQuery(query)) {
engineResult = (await engine.getRecordings(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(query)) {
engineResult = (await engine.getRecordingSegments(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isMediaMetadataQuery(query)) {
} else if (CameraQueryClassifier.isMediaMetadataQuery(query)) {
engineResult = (await engine.getMediaMetadata(
hass,
this._store,
@@ -697,7 +702,7 @@ export class CameraManager {
return results;
}
protected _convertQueryResultsToMedia<QT extends DataQuery>(
protected _convertQueryResultsToMedia<QT extends CameraQuery>(
results: ResultsMap<QT>,
): ViewMedia[] {
const mediaArray: ViewMedia[] = [];
@@ -714,12 +719,12 @@ export class CameraManager {
let media: ViewMedia[] | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (
QueryClassifier.isEventQuery(query) &&
CameraQueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
) {
media = engine.generateMediaFromEvents(hass, this._store, query, result);
} else if (
QueryClassifier.isRecordingQuery(query) &&
CameraQueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQueryResult(result)
) {
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
@@ -729,7 +734,7 @@ export class CameraManager {
}
}
}
return sortMedia(mediaArray);
return sortItems(mediaArray);
}
public getCameraEndpoints(
@@ -1,32 +1,29 @@
import { add, endOfDay, parse, startOfDay } from 'date-fns';
import orderBy from 'lodash-es/orderBy';
import { orderBy } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import {
BrowseMediaStep,
BrowseMediaTarget,
} from '../../utils/ha/browse-media/browse-media-manager';
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
BrowseMediaCache,
BrowseMediaMetadata,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { ViewMedia } from '../../view/media';
} from '../../ha/browse-media/types';
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCamera } from '../browse-media/camera';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
import { MemoryRequestCache } from '../cache';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
@@ -67,8 +64,8 @@ const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
protected _directoryCache = new BrowseMediaCache<BrowseMediaMetadata>();
protected _fileCache = new BrowseMediaCache<BrowseMediaMetadata>();
public getEngineType(): Engine {
return Engine.MotionEye;
@@ -224,7 +221,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
};
// For motionEye snapshots and clips are mutually exclusive.
return await this._browseMediaManager.walkBrowseMedias(
return await this._browseMediaWalker.walk(
hass,
[
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
@@ -291,7 +288,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
);
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
const media = await this._browseMediaManager.walkBrowseMedias(
const media = await this._browseMediaWalker.walk(
hass,
[
{
@@ -431,7 +428,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
const getUIEndpoint = (): Endpoint | null => {
return cameraConfig.motioneye?.url
? {
endpoint: cameraConfig.motioneye.url,
+1 -2
View File
@@ -1,5 +1,4 @@
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from '../browse-media/types';
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/types';
import { Engine, EventQueryResults } from '../types';
// ================================
+1 -1
View File
@@ -1,4 +1,4 @@
import orderBy from 'lodash-es/orderBy';
import { orderBy } from 'lodash-es';
interface Range<T extends Date | number> {
start: T;
+1 -1
View File
@@ -1,10 +1,10 @@
import { ActionsExecutor } from '../../card-controller/actions/types';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { createSelectOptionAction } from '../../utils/action.js';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { BrowseMediaCamera } from '../browse-media/camera';
import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities';
+17 -16
View File
@@ -1,26 +1,26 @@
import { add, endOfDay, parse, startOfDay } from 'date-fns';
import { orderBy } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { sortMediaByStartDate } from '../../utils/ha/browse-media/browse-media-manager';
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
import { sortMediaByStartDate } from '../../ha/browse-media/sort-browse-media-by-start-date';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
BrowseMediaCache,
BrowseMediaMetadata,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { ViewMedia } from '../../view/media';
} from '../../ha/browse-media/types';
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
import { MemoryRequestCache } from '../cache';
import { Camera } from '../camera';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
@@ -48,7 +48,8 @@ export class ReolinkQueryResultsClassifier {
}
export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
protected _cache = new MemoryRequestCache<string, BrowseMedia>();
protected _camerasCache = new BrowseMediaCache<BrowseMediaReolinkCameraMetadata>();
protected _cache = new BrowseMediaCache<BrowseMediaMetadata>();
public getEngineType(): Engine {
return Engine.Reolink;
@@ -172,7 +173,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
// that match the expected camera. Some Reolink cameras will not show up
// here causing errors.
// https://github.com/dermotduffy/advanced-camera-card/issues/1723
const camerasWithMedia = await this._browseMediaManager.walkBrowseMedias(
const camerasWithMedia = await this._browseMediaWalker.walk(
hass,
[
{
@@ -188,7 +189,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
},
],
{
...(engineOptions?.useCache !== false && { cache: this._cache }),
...(engineOptions?.useCache !== false && { cache: this._camerasCache }),
},
);
@@ -196,7 +197,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
return null;
}
return await this._browseMediaManager.walkBrowseMedias(
return await this._browseMediaWalker.walk(
hass,
[
{
@@ -263,7 +264,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
let media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
if (directories?.length) {
media = await this._browseMediaManager.walkBrowseMedias(
media = await this._browseMediaWalker.walk(
hass,
[
{
@@ -395,7 +396,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
const getUIEndpoint = (): Endpoint | null => {
return cameraConfig.reolink?.url
? {
endpoint: cameraConfig.reolink.url,
+1 -2
View File
@@ -1,5 +1,4 @@
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from '../browse-media/types';
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/types';
import { Engine, EventQueryResults } from '../types';
export interface BrowseMediaReolinkCameraMetadata {
+5 -3
View File
@@ -1,7 +1,7 @@
import { CameraConfig } from '../config/schema/cameras';
import { CapabilityKey } from '../types';
import { allPromises } from '../utils/basic';
import { ViewMedia } from '../view/media';
import { ViewMedia } from '../view/item';
import { Camera } from './camera';
import { CameraManagerEngine } from './engine';
import { CapabilitySearchOptions, Engine } from './types';
@@ -133,7 +133,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
}
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
return this.getCameraConfig(media.getCameraID());
const cameraID = media.getCameraID();
return cameraID ? this.getCameraConfig(cameraID) : null;
}
public getEngineOfType(engine: Engine): CameraManagerEngine | null {
@@ -163,7 +164,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
}
public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null {
return this.getEngineForCameraID(media.getCameraID());
const cameraID = media.getCameraID();
return cameraID ? this.getEngineForCameraID(cameraID) : null;
}
/**
+17 -21
View File
@@ -1,7 +1,8 @@
import { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { CapabilityKey, Icon } from '../types';
import { ViewMedia } from '../view/media';
import { CapabilityKey, Endpoint, Icon } from '../types';
import { ViewMedia } from '../view/item';
// ====
// Base
@@ -28,11 +29,11 @@ export enum Engine {
Reolink = 'reolink',
}
export interface DataQuery {
export interface CameraQuery {
type: QueryType;
cameraIDs: Set<string>;
}
export type PartialDataQuery = Partial<DataQuery>;
export type PartialCameraQuery = Partial<CameraQuery>;
interface TimeBasedDataQuery {
start: Date;
@@ -44,7 +45,7 @@ interface LimitedDataQuery {
}
export interface MediaQuery
extends DataQuery,
extends CameraQuery,
Partial<TimeBasedDataQuery>,
Partial<LimitedDataQuery> {
favorite?: boolean;
@@ -100,11 +101,6 @@ interface CapabilitySearchAllAny {
}
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
export interface CameraManagerMediaCapabilities {
canFavorite: boolean;
canDownload: boolean;
}
export interface CameraManagerCameraMetadata {
title: string;
icon: Icon;
@@ -118,16 +114,11 @@ export interface CameraEndpointsContext {
view?: AdvancedCameraCardView;
}
export interface CameraEndpoint {
endpoint: string;
sign?: boolean;
}
export interface CameraEndpoints {
ui?: CameraEndpoint;
go2rtc?: CameraEndpoint;
jsmpeg?: CameraEndpoint;
webrtcCard?: CameraEndpoint;
ui?: Endpoint;
go2rtc?: Endpoint;
jsmpeg?: Endpoint;
webrtcCard?: Endpoint;
}
export interface CameraProxyConfig {
@@ -157,6 +148,11 @@ export interface CameraEvent {
}
export type CameraEventCallback = (ev: CameraEvent) => void;
export class CameraManagerRequestCache extends ExpiringEqualityCache<
CameraQuery,
QueryResults
> {}
// ===========
// Event Query
// ===========
@@ -202,7 +198,7 @@ export interface RecordingQueryResults extends QueryResults {
// Recording Segments Query
// ========================
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
export interface RecordingSegmentsQuery extends CameraQuery, TimeBasedDataQuery {
type: QueryType.RecordingSegments;
}
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
@@ -216,7 +212,7 @@ export interface RecordingSegmentsQueryResults extends QueryResults {
// Media metadata Query
// ====================
export interface MediaMetadataQuery extends DataQuery {
export interface MediaMetadataQuery extends CameraQuery {
type: QueryType.MediaMetadata;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { CameraConfig } from '../../config/schema/cameras';
import { CameraEndpoint } from '../types';
import { Endpoint } from '../../types';
export const getDefaultGo2RTCEndpoint = (
cameraConfig: CameraConfig,
@@ -7,7 +7,7 @@ export const getDefaultGo2RTCEndpoint = (
url?: string;
stream?: string;
},
): CameraEndpoint | null => {
): Endpoint | null => {
const url = options?.url ?? cameraConfig.go2rtc?.url;
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
-16
View File
@@ -1,16 +0,0 @@
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { ViewMedia } from '../../view/media';
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading oldest -> youngest (so media is loaded in this
// order in the viewer which matches the left-to-right timeline order).
(media) => media.getStartTime() ?? media.getID(),
'asc',
);
};
@@ -10,7 +10,7 @@ import {
getActionConfigGivenAction,
isAdvancedCameraCardCustomAction,
} from '../../utils/action.js';
import { allPromises } from '../../utils/basic.js';
import { allPromises, errorToConsole } from '../../utils/basic.js';
import { TemplateRenderer } from '../templates/index.js';
import { CardActionsManagerAPI } from '../types.js';
import { ActionSet } from './actions/set.js';
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
let specificActions: Actions | undefined = undefined;
if (view?.is('live')) {
specificActions = config?.live.actions;
} else if (view?.isGalleryView()) {
} else if (view?.isMediaGalleryView()) {
specificActions = config?.media_gallery?.actions;
} else if (view?.isViewerView()) {
specificActions = config?.media_viewer.actions;
@@ -150,6 +150,7 @@ export class ActionsManager implements ActionsExecutor {
await actionSet.execute(this._api);
forwardHaptic('success');
} catch (e) {
errorToConsole(e as Error);
forwardHaptic('warning');
}
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
@@ -6,6 +6,9 @@ export class DownloadAction extends AdvancedCameraCardAction<GeneralActionConfig
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getDownloadManager().downloadViewerMedia();
const item = api.getViewManager().getView()?.queryResults?.getSelectedResult();
if (item) {
await api.getViewItemManager().download(item);
}
}
}
@@ -0,0 +1,27 @@
import { FolderActionConfig } from '../../../config/schema/actions/custom/folder';
import { FolderViewQuery } from '../../../view/query';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class FolderAction extends AdvancedCameraCardAction<FolderActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const folder = api.getFoldersManager().getFolder(this._action.folder);
if (!folder) {
return;
}
const query = api.getFoldersManager().generateDefaultFolderQuery(folder);
if (!query) {
return;
}
await api.getViewManager().setViewByParametersWithExistingQuery({
params: {
view: 'folder',
query: new FolderViewQuery(query),
},
});
}
}
@@ -1,5 +1,6 @@
import { MediaPlayerActionConfig } from '../../../config/schema/actions/custom/media-player';
import { getStreamCameraID } from '../../../utils/substream';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
@@ -9,15 +10,19 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
const mediaPlayer = this._action.media_player;
const mediaPlayerController = api.getMediaPlayerManager();
const view = api.getViewManager().getView();
const media = view?.queryResults?.getSelectedResult() ?? null;
if (this._action.media_player_action === 'stop') {
await mediaPlayerController.stop(mediaPlayer);
} else if (view?.is('live')) {
return;
}
const view = api.getViewManager().getView();
const item = view?.queryResults?.getSelectedResult() ?? null;
if (view?.is('live')) {
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
} else if (view?.isViewerView() && media) {
await mediaPlayerController.playMedia(mediaPlayer, media);
} else if (view?.isViewerView() && item && ViewItemClassifier.isMedia(item)) {
await mediaPlayerController.playMedia(mediaPlayer, item);
}
}
}
@@ -1,4 +1,4 @@
import clamp from 'lodash-es/clamp';
import { clamp } from 'lodash-es';
import {
PartialZoomSettings,
ZOOM_DEFAULT_PAN_X,
+12 -6
View File
@@ -86,12 +86,15 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
const singleStep = async (): Promise<void> => {
this._action.ptz_action &&
(await api
/* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */
if (this._action.ptz_action) {
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
}));
});
}
if (!this._stopped) {
// Only start the timer for the next step after this step returns, and
@@ -121,13 +124,16 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
});
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
this._action.ptz_action &&
(await api
/* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */
if (this._action.ptz_action) {
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'stop',
}));
});
}
});
}
}
@@ -1,4 +1,6 @@
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import { downloadURL } from '../../../utils/download';
import { generateScreenshotFilename } from '../../../utils/screenshot';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
@@ -6,6 +8,13 @@ export class ScreenshotAction extends AdvancedCameraCardAction<GeneralActionConf
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getDownloadManager().downloadScreenshot();
const url = await api
.getMediaLoadedInfoManager()
.get()
?.mediaPlayerController?.getScreenshotURL();
if (url) {
downloadURL(url, generateScreenshotFilename(api.getViewManager().getView()));
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { SleepActionConfig } from '../../../config/schema/actions/custom/sleep';
import { sleep } from '../../../utils/basic';
import { sleep } from '../../../utils/sleep';
import { CardActionsAPI } from '../../types';
import { timeDeltaToSeconds } from '../utils/time-delta';
import { AdvancedCameraCardAction } from './base';
@@ -3,7 +3,6 @@ import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
+3
View File
@@ -10,6 +10,7 @@ import { DefaultAction } from './actions/default';
import { DisplayModeSelectAction } from './actions/display-mode-select';
import { DownloadAction } from './actions/download';
import { ExpandAction } from './actions/expand';
import { FolderAction } from './actions/folder';
import { FullscreenAction } from './actions/fullscreen';
import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log';
@@ -150,6 +151,8 @@ export class ActionFactory {
return new StatusBarAction(context, action, options?.config);
case INTERNAL_CALLBACK_ACTION:
return new InternalCallbackAction(context, action, options?.config);
case 'folder':
return new FolderAction(context, action, options?.config);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
@@ -1,4 +1,4 @@
import merge from 'lodash-es/merge';
import { merge } from 'lodash-es';
import { Action, TargetedActionContext } from '../types';
import { ActionContext } from 'action';
+3 -2
View File
@@ -1,3 +1,4 @@
import { ViewItemClassifier } from '../view/item-classifier';
import { CardCameraURLAPI } from './types';
export class CameraURLManager {
@@ -20,11 +21,11 @@ export class CameraURLManager {
public getCameraURL(): string | null {
const view = this._api.getViewManager().getView();
const media = view?.queryResults?.getSelectedResult() ?? null;
const item = view?.queryResults?.getSelectedResult() ?? null;
const endpoints = view?.camera
? this._api.getCameraManager().getCameraEndpoints(view.camera, {
view: view.view,
...(media && { media: media }),
...(item && ViewItemClassifier.isMedia(item) && { media: item }),
}) ?? null
: null;
return endpoints?.ui?.endpoint ?? null;
+1 -1
View File
@@ -1,8 +1,8 @@
import { LitElement, ReactiveControllerHost } from 'lit';
import { ActionEventTarget } from '../action-handler-directive';
import { isCardInPanel } from '../ha/panel';
import { setOrRemoveAttribute } from '../utils/basic';
import { isBeingCasted } from '../utils/casting';
import { isCardInPanel } from '../utils/ha';
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
import { InitializationAspect } from './initialization-manager';
import { CardElementAPI } from './types';
+3 -1
View File
@@ -1,4 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { ConditionsManager } from '../../conditions/conditions-manager.js';
import { isConfigUpgradeable } from '../../config/management.js';
import { setProfiles } from '../../config/profiles/set-profiles.js';
@@ -15,6 +15,7 @@ import { CardConfigAPI } from '../types.js';
import { getOverriddenConfig } from './get-overridden-config.js';
import { setAutomationsFromConfig } from './load-automations.js';
import { setRemoteControlEntityFromConfig } from './load-control-entities.js';
import { setFoldersFromConfig } from './load-folders.js';
import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js';
export class ConfigManager {
@@ -136,6 +137,7 @@ export class ConfigManager {
const previousConfig = this._overriddenConfig;
this._overriddenConfig = overriddenConfig;
setFoldersFromConfig(this._api);
this._api.getStyleManager().updateFromConfig();
if (
@@ -1,6 +1,6 @@
import { CardConfigLoaderAPI } from '../types';
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => {
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI): void => {
api.getAutomationsManager().deleteAutomations();
api
.getAutomationsManager()
@@ -0,0 +1,12 @@
import { CardConfigLoaderAPI } from '../types';
export const setFoldersFromConfig = (api: CardConfigLoaderAPI): void => {
api.getFoldersManager().deleteFolders();
try {
api
.getFoldersManager()
.addFolders(api.getConfigManager().getConfig()?.folders ?? []);
} catch (ev) {
api.getMessageManager().setErrorIfHigherPriority(ev);
}
};
+19 -22
View File
@@ -2,17 +2,12 @@ import { ReactiveController } from 'lit';
import { CameraManager } from '../camera-manager/manager';
import { ConditionStateManager } from '../conditions/state-manager';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { DeviceRegistryManager } from '../ha/registry/device';
import { DeviceCache } from '../ha/registry/device/types';
import { EntityRegistryManagerLive } from '../ha/registry/entity';
import { EntityCache, EntityRegistryManager } from '../ha/registry/entity/types';
import { ResolvedMediaCache } from '../ha/resolved-media';
import { LovelaceCardEditor } from '../ha/types';
import {
createDeviceRegistryCache,
DeviceRegistryManager,
} from '../utils/ha/registry/device';
import {
createEntityRegistryCache,
EntityRegistryManagerLive,
} from '../utils/ha/registry/entity';
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { ActionsManager } from './actions/actions-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
@@ -24,8 +19,8 @@ import {
} from './card-element-manager';
import { ConfigManager } from './config/config-manager';
import { DefaultManager } from './default-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FoldersManager } from './folders/manager';
import { FullscreenManager } from './fullscreen/fullscreen-manager';
import { HASSManager } from './hass/hass-manager';
import { InitializationManager } from './initialization-manager';
@@ -65,6 +60,7 @@ import {
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewItemManager } from './view/item-manager';
import { ViewManager } from './view/view-manager';
export class CardController
@@ -98,12 +94,8 @@ export class CardController
// These properties may be used in the construction of 'managers' (and should
// be created first).
protected _deviceRegistryManager = new DeviceRegistryManager(
createDeviceRegistryCache(),
);
protected _entityRegistryManager = new EntityRegistryManagerLive(
createEntityRegistryCache(),
);
protected _deviceRegistryManager = new DeviceRegistryManager(new DeviceCache());
protected _entityRegistryManager = new EntityRegistryManagerLive(new EntityCache());
protected _resolvedMediaCache = new ResolvedMediaCache();
protected _actionsManager = new ActionsManager(this, new TemplateRenderer());
@@ -113,8 +105,8 @@ export class CardController
protected _cardElementManager: CardElementManager;
protected _configManager = new ConfigManager(this);
protected _defaultManager = new DefaultManager(this);
protected _downloadManager = new DownloadManager(this);
protected _expandManager = new ExpandManager(this);
protected _foldersManager = new FoldersManager(this);
protected _fullscreenManager = new FullscreenManager(this);
protected _hassManager = new HASSManager(this);
protected _initializationManager = new InitializationManager(this);
@@ -129,6 +121,7 @@ export class CardController
protected _styleManager = new StyleManager(this);
protected _triggersManager = new TriggersManager(this);
protected _viewManager = new ViewManager(this);
protected _viewItemManager = new ViewItemManager(this);
constructor(
host: CardHTMLElement,
@@ -193,10 +186,6 @@ export class CardController
return this._deviceRegistryManager;
}
public getDownloadManager(): DownloadManager {
return this._downloadManager;
}
public getEntityRegistryManager(): EntityRegistryManager {
return this._entityRegistryManager;
}
@@ -205,6 +194,10 @@ export class CardController
return this._expandManager;
}
public getFoldersManager(): FoldersManager {
return this._foldersManager;
}
public getFullscreenManager(): FullscreenManager {
return this._fullscreenManager;
}
@@ -282,6 +275,10 @@ export class CardController
return this._viewManager;
}
public getViewItemManager(): ViewItemManager {
return this._viewItemManager;
}
// *************************************************************************
// Handlers
// *************************************************************************
+1 -1
View File
@@ -1,4 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { createGeneralAction } from '../utils/action';
import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode';
-41
View File
@@ -1,41 +0,0 @@
import { downloadMedia, downloadURL } from '../utils/download';
import { generateScreenshotFilename } from '../utils/screenshot';
import { CardDownloadAPI } from './types';
export class DownloadManager {
protected _api: CardDownloadAPI;
constructor(api: CardDownloadAPI) {
this._api = api;
}
public async downloadViewerMedia(): Promise<boolean> {
const media = this._api
.getViewManager()
.getView()
?.queryResults?.getSelectedResult();
const hass = this._api.getHASSManager().getHASS();
if (!media || !hass) {
return false;
}
try {
await downloadMedia(hass, this._api.getCameraManager(), media);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return false;
}
return true;
}
public async downloadScreenshot(): Promise<void> {
const url = await this._api
.getMediaLoadedInfoManager()
.get()
?.mediaPlayerController?.getScreenshotURL();
if (url) {
downloadURL(url, generateScreenshotFilename(this._api.getViewManager().getView()));
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { sortItems } from '../view/sort';
import { HAFoldersEngine } from './ha/engine';
import { DownloadHelpers, EngineOptions, FolderQuery, FoldersEngine } from './types';
export class FoldersExecutor {
private _ha: FoldersEngine;
constructor(engines?: { ha?: HAFoldersEngine }) {
this._ha = engines?.ha ?? new HAFoldersEngine();
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
return (
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
);
}
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const results =
(await this._getFolderEngine(query.folder.type)?.expandFolder(
hass,
query,
engineOptions,
)) ?? null;
return results ? sortItems(results) : null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return (
this._getFolderEngine(item.getFolder()?.type)?.getItemCapabilities(item) ?? null
);
}
public async getDownloadPath(
hass: HomeAssistant | null,
item: ViewItem,
helpers?: DownloadHelpers,
): Promise<Endpoint | null> {
return await (this._getFolderEngine(item.getFolder()?.type)?.getDownloadPath(
hass,
item,
helpers,
) ?? null);
}
public async favorite(
hass: HomeAssistant | null,
item: ViewItem,
favorite: boolean,
): Promise<void> {
return await this._getFolderEngine(item.getFolder()?.type)?.favorite(
hass,
item,
favorite,
);
}
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
switch (type) {
case folderTypeSchema.enum.ha:
return this._ha;
}
return null;
}
}
+180
View File
@@ -0,0 +1,180 @@
import { NonEmptyTuple } from 'type-fest';
import {
FolderConfig,
folderTypeSchema,
HA_MEDIA_SOURCE_ROOT,
HAFolderConfig,
HAFolderPathComponent,
} from '../../../config/schema/folders';
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
import { BrowseMedia, BrowseMediaCache } from '../../../ha/browse-media/types';
import {
BrowseMediaStep,
BrowseMediaTarget,
BrowseMediaWalker,
} from '../../../ha/browse-media/walker';
import { getMediaDownloadPath } from '../../../ha/download';
import { HomeAssistant } from '../../../ha/types';
import { Endpoint } from '../../../types';
import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { ViewItemCapabilities } from '../../../view/types';
import {
DownloadHelpers,
EngineOptions,
FolderPathComponent,
FolderQuery,
FoldersEngine,
} from '../types';
export class HAFoldersEngine implements FoldersEngine {
private _browseMediaManager: BrowseMediaWalker;
private _cache = new BrowseMediaCache();
public constructor(browseMediaManager?: BrowseMediaWalker) {
this._browseMediaManager = browseMediaManager ?? new BrowseMediaWalker();
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return {
canFavorite: false,
canDownload: !ViewItemClassifier.isFolder(item),
};
}
public async getDownloadPath(
hass: HomeAssistant,
item: ViewItem,
helpers?: DownloadHelpers,
): Promise<Endpoint | null> {
if (!ViewItemClassifier.isMedia(item)) {
return null;
}
return getMediaDownloadPath(hass, item.getContentID(), helpers?.resolvedMediaCache);
}
public async favorite(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_hass: HomeAssistant,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_item: ViewItem,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_favorite: boolean,
): Promise<void> {
return;
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
if (folder.type !== folderTypeSchema.enum.ha) {
return null;
}
return {
folder,
path: this.getDefaultFolderPathComponents(folder.ha),
};
}
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
if (query.folder.type !== folderTypeSchema.enum.ha) {
return null;
}
const pathComponents = [...query.path];
// Search through the path components from the start to find the last
// component with a precise media source id, which is where the queries
// start (and may drill down from).
let start: string | null = null;
while (pathComponents.length > 0) {
const id = pathComponents[0]?.id;
if (id) {
start = id;
pathComponents.shift();
} else {
break;
}
}
// If no media source id is found, return null, as there is no "starting
// query".
if (start === null) {
return null;
}
// This matcher matches a browse media against a given path component.
const componentMatcher = (
media: BrowseMedia,
component?: FolderPathComponent,
): boolean => {
return (
!component ||
(media.can_expand &&
(component.ha?.title === media.title ||
(component.ha?.title_re &&
new RegExp(component.ha.title_re).test(media.title)) ||
component.id === media.media_content_id))
);
};
// Generate a walk step, optionally matching against the next path component
// (if any), otherwise just returning all the media at this level.
const generateStep = (targets: BrowseMediaTarget[]): BrowseMediaStep[] => {
const nextComponent = pathComponents.shift();
return [
{
targets,
...(nextComponent && {
matcher: (media: BrowseMedia) => componentMatcher(media, nextComponent),
advance: (targets) => generateStep(targets),
}),
},
];
};
const browseMedia = await this._browseMediaManager.walk(
hass,
generateStep([start]),
{
...((engineOptions?.useCache ?? true) && { cache: this._cache }),
},
);
return getViewItemsFromBrowseMediaArray(browseMedia, {
folder: query.folder,
});
}
private getDefaultFolderPathComponents(
haFolderConfig?: HAFolderConfig,
): NonEmptyTuple<FolderPathComponent> {
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id;
const defaultPath = [
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
...(haFolderConfig?.url ?? []),
...(haFolderConfig?.path ?? []),
];
return defaultPath.map((component) =>
this._convertHAPathComponentToFolderPathComponent(component),
) as [FolderPathComponent, ...FolderPathComponent[]];
}
// Convert from the HA folder path component config schema to the general,
// which pulls `path` to the top level.
private _convertHAPathComponentToFolderPathComponent(
component: HAFolderPathComponent,
): FolderPathComponent {
return {
id: component.id,
ha: {
...component,
},
};
}
}
+90
View File
@@ -0,0 +1,90 @@
import { cloneDeep } from 'lodash-es';
import { FolderConfig } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { CardFoldersAPI } from '../types';
import { FoldersExecutor } from './executor';
import { EngineOptions, FolderInitializationError, FolderQuery } from './types';
export class FoldersManager {
private _api: CardFoldersAPI;
private _executor: FoldersExecutor;
private _folders: Map<string, FolderConfig> = new Map();
constructor(api: CardFoldersAPI, executor?: FoldersExecutor) {
this._api = api;
this._executor = executor ?? new FoldersExecutor();
}
public deleteFolders(): void {
this._folders.clear();
}
public addFolders(folders: FolderConfig[]): void {
for (const folder of folders) {
const folderNumber = this._folders.size;
const id = folder.id ?? `folder/${folderNumber.toString()}`;
if (this._folders.has(id)) {
throw new FolderInitializationError(
localize('error.duplicate_folder_id'),
folder,
);
}
this._folders.set(id, {
title: `${localize('common.folder')} ${folderNumber}`,
...cloneDeep(folder),
id,
});
}
}
public getFolderCount(): number {
return this._folders.size;
}
public getFolders(): MapIterator<[string, FolderConfig]> {
return this._folders.entries();
}
public getFolder(id?: string): FolderConfig | null {
return id
? this._folders.get(id) ?? null
: this._folders.values().next().value ?? null;
}
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
const _folder = folder ?? this.getFolder();
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
}
public async expandFolder(
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const hass = this._api.getHASSManager().getHASS();
return hass ? this._executor.expandFolder(hass, query, engineOptions) : null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return this._executor.getItemCapabilities(item);
}
public async getDownloadPath(item: ViewItem): Promise<Endpoint | null> {
return await this._executor.getDownloadPath(
this._api.getHASSManager().getHASS(),
item,
{
resolvedMediaCache: this._api.getResolvedMediaCache(),
},
);
}
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
return await this._executor.favorite(
this._api.getHASSManager().getHASS(),
item,
favorite,
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NonEmptyTuple } from 'type-fest';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { AdvancedCameraCardError } from '../../types.js';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
// ====
// Base
// ====
export interface EngineOptions {
useCache?: boolean;
}
export class FolderInitializationError extends AdvancedCameraCardError {}
// ============
// Folder Query
// ============
export type FolderPathComponent = {
id?: string;
ha?: Omit<HAFolderPathComponent, 'id'>;
};
export interface FolderQuery {
folder: FolderConfig;
// A trail of paths to navigate back to the "root", with the last path being
// the path that this query directly refers to.
path: NonEmptyTuple<FolderPathComponent>;
}
// ===============
// Folders Engines
// ===============
export interface DownloadHelpers {
resolvedMediaCache?: ResolvedMediaCache | null;
}
export interface FoldersEngine {
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null>;
getItemCapabilities(item: ViewItem): ViewItemCapabilities | null;
getDownloadPath(
hass: HomeAssistant | null,
item: ViewItem,
options?: DownloadHelpers,
): Promise<Endpoint | null>;
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { hasHAConnectionStateChanged } from '../../ha/has-hass-connection-changed';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { hasHAConnectionStateChanged } from '../../utils/ha';
import { CardHASSAPI } from '../types';
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
+2 -2
View File
@@ -1,5 +1,5 @@
import { HomeAssistant } from '../../ha/types';
import { getHassDifferences, HassStateDifference } from '../../utils/ha';
import { getHassDifferences } from '../../ha/get-hass-differences';
import { HassStateDifference, HomeAssistant } from '../../ha/types';
type StateWatcherCallback = (difference: HassStateDifference) => void;
@@ -1,6 +1,6 @@
import PQueue from 'p-queue';
import { loadLanguages } from '../localize/localize';
import { sideLoadHomeAssistantElements } from '../utils/ha';
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
import { Initializer } from '../utils/initializer/initializer';
import { CardInitializerAPI } from './types';
@@ -42,6 +42,10 @@ export class InitializationManager {
return this._everInitialized;
}
public isInitialized(aspect: InitializationAspect): boolean {
return this._initializer.isInitialized(aspect);
}
public isInitializedMandatory(): boolean {
const config = this._api.getConfigManager().getConfig();
if (!config) {
+1 -1
View File
@@ -1,4 +1,4 @@
import throttle from 'lodash-es/throttle';
import { throttle } from 'lodash-es';
import { setOrRemoveAttribute } from '../utils/basic';
import { Timer } from '../utils/timer';
import { CardInteractionAPI } from './types';
@@ -1,5 +1,5 @@
import { CardKeyboardStateAPI, KeysState } from './types';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
export class KeyboardStateManager {
protected _api: CardKeyboardStateAPI;
+5 -5
View File
@@ -5,12 +5,12 @@ import {
MEDIA_PLAYER_SUPPORT_STOP,
MEDIA_PLAYER_SUPPORT_TURN_OFF,
} from '../const';
import { Entity } from '../ha/registry/entity/types';
import { supportsFeature } from '../ha/supports-feature';
import { localize } from '../localize/localize';
import { errorToConsole } from '../utils/basic';
import { supportsFeature } from '../utils/ha';
import { Entity } from '../utils/ha/registry/entity/types';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import { ViewMedia } from '../view/item';
import { ViewItemClassifier } from '../view/item-classifier';
import { CardMediaPlayerAPI } from './types';
export class MediaPlayerManager {
@@ -196,7 +196,7 @@ export class MediaPlayerManager {
await hass.callService('media_player', 'play_media', {
entity_id: mediaPlayer,
media_content_id: media.getContentID(),
media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image',
media_content_type: ViewItemClassifier.isVideo(media) ? 'video' : 'image',
extra: {
...(title && { title: title }),
...(thumbnail && { thumb: thumbnail }),
@@ -1,4 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager';
import { StatusBarItem } from '../config/schema/actions/types';
import { StatusBarConfig } from '../config/schema/status-bar';
+1 -2
View File
@@ -1,5 +1,4 @@
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import { orderBy, throttle } from 'lodash-es';
import { CameraEvent } from '../camera-manager/types';
import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types';
+17 -4
View File
@@ -1,16 +1,16 @@
import type { CameraManager } from '../camera-manager/manager';
import type { ConditionStateManager } from '../conditions/state-manager';
import type { Automation } from '../config/schema/automations';
import type { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
import type { EntityRegistryManager } from '../ha/registry/entity/types';
import type { ResolvedMediaCache } from '../ha/resolved-media';
import type { ActionsManager } from './actions/actions-manager';
import type { AutomationsManager } from './automations-manager';
import type { CameraURLManager } from './camera-url-manager';
import type { CardElementManager } from './card-element-manager';
import type { ConfigManager } from './config/config-manager';
import type { DefaultManager } from './default-manager';
import type { DownloadManager } from './download-manager';
import type { ExpandManager } from './expand-manager';
import type { FoldersManager } from './folders/manager';
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
import type { HASSManager } from './hass/hass-manager';
import type { InitializationManager } from './initialization-manager';
@@ -24,6 +24,7 @@ import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewItemManager } from './view/item-manager';
import type { ViewManager } from './view/view-manager';
// *************************************************************************
@@ -40,8 +41,8 @@ export interface CardActionsAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getDownloadManager(): DownloadManager;
getExpandManager(): ExpandManager;
getFoldersManager(): FoldersManager;
getFullscreenManager(): FullscreenManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
@@ -50,6 +51,7 @@ export interface CardActionsAPI {
getMicrophoneManager(): MicrophoneManager;
getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager;
getViewItemManager(): ViewItemManager;
getViewManager(): ViewManager;
}
export type CardActionsManagerAPI = CardActionsAPI;
@@ -90,6 +92,7 @@ export interface CardConfigAPI {
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getDefaultManager(): DefaultManager;
getFoldersManager(): FoldersManager;
getHASSManager(): HASSManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
@@ -104,6 +107,8 @@ export interface CardConfigAPI {
export interface CardConfigLoaderAPI {
getAutomationsManager(): AutomationsManager;
getConfigManager(): ConfigManager;
getFoldersManager(): FoldersManager;
getMessageManager(): MessageManager;
getHASSManager(): HASSManager;
}
@@ -148,6 +153,12 @@ export interface CardExpandAPI {
getFullscreenManager(): FullscreenManager;
}
export interface CardFoldersAPI {
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getResolvedMediaCache(): ResolvedMediaCache;
}
export interface CardFullscreenAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
@@ -270,7 +281,9 @@ export interface CardViewAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getFoldersManager(): FoldersManager;
getHASSManager(): HASSManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager;
+131
View File
@@ -0,0 +1,131 @@
import { format } from 'date-fns';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { downloadURL } from '../../utils/download';
import { homeAssistantSignPath } from '../../ha/sign-path';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
import { CardViewAPI } from '../types';
enum ViewMediaSource {
Camera = 'camera',
Folder = 'folder',
}
export class ViewItemManager {
private _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getCapabilities(item: ViewItem): ViewItemCapabilities | null {
const source = this._getMediaSource(item);
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
return this._api.getCameraManager().getMediaCapabilities(item);
}
if (source === ViewMediaSource.Folder) {
return this._api.getFoldersManager().getItemCapabilities(item);
}
return null;
}
public async download(item: ViewItem): Promise<boolean> {
try {
await this._download(item);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return false;
}
return true;
}
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
const source = this._getMediaSource(item);
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
return await this._api.getCameraManager().favoriteMedia(item, favorite);
}
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (source === ViewMediaSource.Folder) {
return this._api.getFoldersManager().favorite(item, favorite);
}
}
private _getMediaSource(item: ViewItem): ViewMediaSource | null {
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
return ViewMediaSource.Camera;
}
if (ViewItemClassifier.isFolder(item) || item.getFolder()) {
return ViewMediaSource.Folder;
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
private async _download(item: ViewItem): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
const source = this._getMediaSource(item);
const endpoint =
source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)
? await this._api.getCameraManager().getMediaDownloadPath(item)
: source === ViewMediaSource.Folder
? await this._api.getFoldersManager().getDownloadPath(item)
: null;
if (!endpoint) {
throw new AdvancedCameraCardError(localize('error.download_no_media'));
}
let finalURL = endpoint.endpoint;
if (endpoint.sign) {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, endpoint.endpoint);
} catch (e) {
errorToConsole(e as Error);
}
if (!response) {
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
}
finalURL = response;
}
downloadURL(finalURL, this._generateDownloadFilename(item));
}
private _generateDownloadFilename(item: ViewItem): string {
const toFilename = (input: string): string => {
return input.toLowerCase().replaceAll(/[^a-z0-9]/gi, '-');
};
if (ViewItemClassifier.isMedia(item)) {
const cameraID = item.getCameraID();
const id = item.getID();
const startTime = item.getStartTime();
return (
(cameraID ? toFilename(cameraID) : 'media') +
(id ? `_${toFilename(id)}` : '') +
(startTime ? `_${format(startTime, `yyyy-MM-dd-HH-mm-ss`)}` : '') +
('.' + (item.getMediaType() === 'clip' ? 'mp4' : 'jpg'))
);
}
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (ViewItemClassifier.isFolder(item)) {
return toFilename(item.getTitle() ?? 'media');
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return 'download';
}
}
@@ -1,16 +1,13 @@
import { MediaQueries } from '../../../view/media-queries';
import { MediaQueriesResults } from '../../../view/media-queries-results';
import { Query } from '../../../view/query';
import { QueryResults } from '../../../view/query-results';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SetQueryViewModifier implements ViewModifier {
protected _query?: MediaQueries | null;
protected _queryResults?: MediaQueriesResults | null;
protected _query?: Query | null;
protected _queryResults?: QueryResults | null;
constructor(options?: {
query?: MediaQueries | null;
queryResults?: MediaQueriesResults | null;
}) {
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
this._query = options?.query;
this._queryResults = options?.queryResults;
}
+71 -26
View File
@@ -1,13 +1,17 @@
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaIndex } from '../../utils/find-best-media-index';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
import { ViewItem } from '../../view/item';
import {
EventMediaQueries,
EventMediaQuery,
FolderViewQuery,
MediaQueries,
RecordingMediaQueries,
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
Query,
RecordingMediaQuery,
} from '../../view/query';
import { QueryClassifier } from '../../view/query-classifier';
import { QueryResults } from '../../view/query-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryExecutorResult } from './types';
@@ -48,14 +52,8 @@ export class QueryExecutor {
if (!rawQueries) {
return null;
}
const queries = new EventMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results
? {
query: queries,
queryResults: results,
}
: null;
const queries = new EventMediaQuery(rawQueries);
return await this.executeMediaQuery(queries, options?.executorOptions);
}
public async executeDefaultRecordingQuery(options?: {
@@ -76,16 +74,30 @@ export class QueryExecutor {
if (!rawQueries) {
return null;
}
const queries = new RecordingMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results ? { query: queries, queryResults: results } : null;
const queries = new RecordingMediaQuery(rawQueries);
return await this.executeMediaQuery(queries, options?.executorOptions);
}
public async execute(
public async executeQuery(
query: Query,
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (QueryClassifier.isMediaQuery(query)) {
return await this.executeMediaQuery(query, executorOptions);
} else if (QueryClassifier.isFolderQuery(query)) {
return await this._executeFolderQuery(query, executorOptions);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
public async executeMediaQuery(
query: MediaQueries,
executorOptions?: QueryExecutorOptions,
): Promise<MediaQueriesResults | null> {
const queries = query.getQueries();
): Promise<QueryExecutorResult | null> {
const queries = query.getQuery();
if (!queries) {
return null;
}
@@ -95,11 +107,17 @@ export class QueryExecutor {
.executeMediaQueries<MediaQuery>(queries, {
useCache: executorOptions?.useCache,
});
if (!mediaArray) {
return null;
}
const queryResults = mediaArray
? this._generateQueriesResults(mediaArray, executorOptions)
: null;
return queryResults ? { query, queryResults } : null;
}
const queryResults = new MediaQueriesResults({ results: mediaArray });
private _generateQueriesResults(
itemArray: ViewItem[],
executorOptions?: QueryExecutorOptions,
): QueryResults | null {
const queryResults = new QueryResults({ results: itemArray });
if (executorOptions?.rejectResults?.(queryResults)) {
return null;
}
@@ -111,9 +129,9 @@ export class QueryExecutor {
} else if (executorOptions?.selectResult?.func) {
queryResults.selectResultIfFound(executorOptions.selectResult.func);
} else if (executorOptions?.selectResult?.time) {
queryResults.selectBestResult((media) =>
findBestMediaIndex(
media,
queryResults.selectBestResult((itemArray) =>
findBestMediaTimeIndex(
itemArray,
executorOptions.selectResult?.time?.time as Date,
executorOptions.selectResult?.time?.favorCameraID,
),
@@ -122,6 +140,33 @@ export class QueryExecutor {
return queryResults;
}
public async executeDefaultFolderQuery(
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
const query = this._api.getFoldersManager().generateDefaultFolderQuery();
return query
? this._executeFolderQuery(new FolderViewQuery(query), executorOptions)
: null;
}
private async _executeFolderQuery(
query: FolderViewQuery,
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
const rawQuery = query.getQuery();
if (!rawQuery) {
return null;
}
const itemArray = await this._api
.getFoldersManager()
.expandFolder(rawQuery, { useCache: executorOptions?.useCache });
const queryResults = itemArray
? this._generateQueriesResults(itemArray, executorOptions)
: null;
return queryResults ? { query, queryResults } : null;
}
protected _getChunkLimit(): number {
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
return (
+23
View File
@@ -0,0 +1,23 @@
import { orderBy, uniqBy } from 'lodash-es';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
export const sortItems = <T extends ViewItem>(itemArray: T[]): T[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(itemArray, (item) => item.getID() ?? item),
[
// Pull folders to the front.
(item) => !ViewItemClassifier.isFolder(item),
// Sort by time and id.
(item) =>
ViewItemClassifier.isMedia(item)
? item.getStartTime() ?? item.getID()
: item.getID(),
],
['asc', 'asc'],
);
};
+7 -7
View File
@@ -1,9 +1,9 @@
import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const.js';
import { AdvancedCameraCardError } from '../../types.js';
import { MediaQueriesResults } from '../../view/media-queries-results.js';
import { MediaQueries } from '../../view/media-queries.js';
import { ViewMedia } from '../../view/media.js';
import { ViewItem } from '../../view/item.js';
import { QueryResults } from '../../view/query-results.js';
import { Query } from '../../view/query.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
@@ -20,15 +20,15 @@ export interface QueryExecutorOptions {
favorCameraID?: string;
};
id?: string;
func?: (media: ViewMedia) => boolean;
func?: (media: ViewItem) => boolean;
};
rejectResults?: (results: MediaQueriesResults) => boolean;
rejectResults?: (results: QueryResults) => boolean;
useCache?: boolean;
}
export interface QueryExecutorResult {
query: MediaQueries;
queryResults: MediaQueriesResults;
query: Query;
queryResults: QueryResults;
}
export interface ViewFactoryOptions {
+28 -4
View File
@@ -2,9 +2,10 @@ import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { QueryClassifier } from '../../view/query-classifier';
import { View } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
import { applyViewModifiers } from './modifiers';
@@ -103,6 +104,10 @@ export class ViewManager implements ViewManagerInterface {
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
if (!this._isAllowedToSetView()) {
return;
}
let view: View | null = null;
try {
view = viewFactoryFunc({
@@ -112,7 +117,9 @@ export class ViewManager implements ViewManagerInterface {
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
if (view) {
this._setView(view);
}
}
protected _markViewLoadingQuery(view: View, index: number): View {
@@ -122,6 +129,19 @@ export class ViewManager implements ViewManagerInterface {
return view.removeContextProperty('loading', 'query');
}
protected _isAllowedToSetView(): boolean {
// It is possible to have a race condition where the view is being set at
// the same time as the cameras being initialized. Test case: Open
// folder-based media in the media viewer carousel, then attempt to edit the
// card -- this causes the cameras to re-initialize at the same time as
// folder media is reporting observed zoom settings in the view context.
// Without this check, that will result in a "No cameras support this view"
// message.
return this._api
.getInitializationManager()
.isInitialized(InitializationAspect.CAMERAS);
}
protected async _setViewThenModifyAsync(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
viewModifiersFunc: (
@@ -130,6 +150,10 @@ export class ViewManager implements ViewManagerInterface {
) => Promise<ViewModifier[] | null>,
options?: ViewFactoryOptions,
): Promise<void> {
if (!this._isAllowedToSetView()) {
return;
}
let initialView: View | null = null;
try {
initialView = viewFactoryFunc({
@@ -220,10 +244,10 @@ export class ViewManager implements ViewManagerInterface {
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
const switchingFromViewerToGallery =
this._view?.isViewerView() && newView?.isGalleryView();
this._view?.isViewerView() && newView?.isMediaGalleryView();
const newMediaType = newView?.getDefaultMediaType();
const alreadyHasMatchingQuery =
MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
}
@@ -29,7 +29,9 @@ export class ViewQueryExecutor {
return view.query
? [
new SetQueryViewModifier({
queryResults: await this._executor.execute(view.query, queryExecutorOptions),
queryResults: (
await this._executor.executeQuery(view.query, queryExecutorOptions)
)?.queryResults,
}),
]
: [];
@@ -83,6 +85,12 @@ export class ViewQueryExecutor {
return results ? [new SetQueryViewModifier(results)] : [];
};
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
const results =
await this._executor.executeDefaultFolderQuery(queryExecutorOptions);
return results ? [new SetQueryViewModifier(results)] : [];
};
switch (view.view) {
case 'live':
if (config.live.controls.thumbnails.mode !== 'none') {
@@ -111,6 +119,9 @@ export class ViewQueryExecutor {
case 'recordings':
viewModifiers.push(...(await executeMediaQuery(mediaType)));
break;
case 'folder':
viewModifiers.push(...(await executeFolderQuery()));
break;
}
viewModifiers.push(...this._getTimelineWindowViewModifier(view));
+5 -3
View File
@@ -247,12 +247,13 @@ class AdvancedCameraCard extends LitElement {
this._config,
this._controller.getCameraManager(),
{
inExpandedMode: this._controller.getExpandManager().isExpanded(),
fullscreenManager: this._controller.getFullscreenManager(),
currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
foldersManager: this._controller.getFoldersManager(),
fullscreenManager: this._controller.getFullscreenManager(),
inExpandedMode: this._controller.getExpandManager().isExpanded(),
mediaPlayerController: this._controller.getMediaPlayerManager(),
microphoneManager: this._controller.getMicrophoneManager(),
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
view: view,
viewManager: this._controller.getViewManager(),
},
@@ -371,6 +372,7 @@ class AdvancedCameraCard extends LitElement {
.hass=${this._hass}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.viewItemManager=${this._controller.getViewItemManager()}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.config=${this._controller.getConfigManager().getConfig()}
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
+49
View File
@@ -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;
};
+19 -16
View File
@@ -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 -2
View File
@@ -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';
+2 -1
View File
@@ -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.
}
}
+64 -5
View File
@@ -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.
+8 -7
View File
@@ -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 -2
View File
@@ -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;
}
}
+4 -3
View File
@@ -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 -2
View File
@@ -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 {
+13 -11
View File
@@ -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,
}),
),
],
});
};
+1 -1
View File
@@ -2,11 +2,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { RawAdvancedCameraCardConfig } from '../config/types';
import { DeviceRegistryManager } from '../ha/registry/device';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import basicBlockStyle from '../scss/basic-block.scss';
import { getDiagnostics } from '../utils/diagnostics';
import { DeviceRegistryManager } from '../utils/ha/registry/device';
import { renderMessage } from './message';
@customElement('advanced-camera-card-diagnostics')
+1 -10
View File
@@ -64,9 +64,6 @@ export class AdvancedCameraCardDrawer extends LitElement {
this._refDrawer.value?.shadowRoot?.appendChild(style);
}
/**
* Called when the slotted children in the drawer change.
*/
protected _slotChanged(): void {
const children = this._refSlot.value
? getChildrenFromElement(this._refSlot.value)
@@ -80,10 +77,6 @@ export class AdvancedCameraCardDrawer extends LitElement {
this._hideDrawerIfNecessary();
}
/**
* Hide the drawer if there is nothing to show.
* @returns
*/
protected _hideDrawerIfNecessary(): void {
if (!this._refDrawer.value) {
return;
@@ -108,9 +101,7 @@ export class AdvancedCameraCardDrawer extends LitElement {
location="${this.location}"
?open=${this.open}
@mouseleave=${() => {
if (this.open) {
this.open = false;
}
this.open = false;
}}
>
${this.control
-506
View File
@@ -1,506 +0,0 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import throttle from 'lodash-es/throttle';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { GalleryConfig } from '../config/schema/gallery.js';
import { CardWideConfig, configDefaults } from '../config/schema/types.js';
import { HomeAssistant } from '../ha/types.js';
import { localize } from '../localize/localize';
import galleryCoreStyle from '../scss/gallery-core.scss';
import galleryStyle from '../scss/gallery.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, sleep } from '../utils/basic';
import { scrollIntoView } from '../utils/scroll.js';
import { ViewMedia } from '../view/media';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import './media-filter';
import './message.js';
import { renderMessage } from './message.js';
import './progress-indicator.js';
import { renderProgressIndicator } from './progress-indicator.js';
import './surround-basic';
import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
const GALLERY_MEDIA_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline',
open: 'mdi:filter-cog',
};
const MIN_GALLERY_EXTENSION_SECONDS = 0.5;
@customElement('advanced-camera-card-gallery')
export class AdvancedCameraCardGallery extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: GalleryConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
const view = this.viewManagerEpoch?.manager.getView();
if (
!this.hass ||
!view?.isGalleryView() ||
!this.cameraManager ||
!this.cardWideConfig
) {
return;
}
return html`
<advanced-camera-card-surround-basic
.drawerIcons=${{
...(this.galleryConfig &&
this.galleryConfig.controls.filter.mode !== 'none' && {
[this.galleryConfig.controls.filter.mode]: GALLERY_MEDIA_FILTER_MENU_ICONS,
}),
}}
>
${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
? html` <advanced-camera-card-media-filter
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.viewManagerEpoch=${this.viewManagerEpoch}
.cardWideConfig=${this.cardWideConfig}
slot=${this.galleryConfig.controls.filter.mode}
>
</advanced-camera-card-media-filter>`
: ''}
<advanced-camera-card-gallery-core
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.galleryConfig}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-gallery-core>
</advanced-camera-card-surround-basic>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(galleryStyle);
}
}
@customElement('advanced-camera-card-gallery-core')
export class AdvancedCameraCardGalleryCore extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: GalleryConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _intersectionObserver: IntersectionObserver;
protected _resizeObserver: ResizeObserver;
protected _refLoaderBottom: Ref<HTMLElement> = createRef();
protected _refSelected: Ref<HTMLElement> = createRef();
// Bottom loader: A progress indicator shown in a "cell" (not across) at the
// bottom of the gallery. Once visible this attempts to fetch new content from
// "earlier" (less recently) than the current query. This is rendered by
// default (and once visible, the fetch is triggered after which it is
// re-hidden).
@state()
protected _showLoaderBottom = true;
// Top loader: A progress indicator is shown across the top of the gallery if
// the user is _already_ at the top of the gallery and scrolls upwards. This
// attempts to fetch new content from "later" (more recently) than the current
// query. This is hidden by default.
@state()
protected _showLoaderTop = false;
protected _media?: ViewMedia[];
protected _boundWheelHandler = this._wheelHandler.bind(this);
protected _boundTouchStartHandler = this._touchStartHandler.bind(this);
protected _boundTouchEndHandler = this._touchEndHandler.bind(this);
// Wheel / touch events may be voluminous, throttle extension calls.
protected _throttleExtendGalleryLater = throttle(
this._extendGallery.bind(this),
MIN_GALLERY_EXTENSION_SECONDS * 1000,
{
leading: true,
trailing: false,
},
);
protected _touchScrollYPosition: number | null = null;
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
// 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).
protected _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;
}
}
protected async _touchEndHandler(ev: TouchEvent): Promise<void> {
if (
!this.scrollTop &&
ev.changedTouches.length === 1 &&
this._touchScrollYPosition
) {
if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
await this._extendLater();
}
}
this._touchScrollYPosition = null;
}
protected async _wheelHandler(ev: WheelEvent): Promise<void> {
if (!this.scrollTop && ev.deltaY < 0) {
await this._extendLater();
}
}
protected async _extendLater(): Promise<void> {
const start = new Date();
this._showLoaderTop = true;
await this._throttleExtendGalleryLater(
'later',
// Ask the engine to avoid use of cache since the user is explicitly
// looking for the freshest possible data.
false,
);
const delta = new Date().getTime() - start.getTime();
if (delta < MIN_GALLERY_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 last long
// enough they can 'feel' the fetch has happened.
await sleep(MIN_GALLERY_EXTENSION_SECONDS - delta / 1000);
}
this._showLoaderTop = false;
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this._resizeObserver.observe(this);
this.addEventListener('wheel', this._boundWheelHandler, { passive: true });
this.addEventListener('touchstart', this._boundTouchStartHandler, { passive: true });
this.addEventListener('touchend', this._boundTouchEndHandler);
// Request update in order to ensure the intersection observer reconnects
// with the loader sentinel.
this.requestUpdate();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this.removeEventListener('wheel', this._boundWheelHandler);
this.removeEventListener('touchstart', this._boundTouchStartHandler);
this.removeEventListener('touchend', this._boundTouchEndHandler);
this._resizeObserver.disconnect();
this._intersectionObserver.disconnect();
super.disconnectedCallback();
}
/**
* Set gallery columns.
*/
protected _setColumnCount(): void {
const thumbnailSize =
this.galleryConfig?.controls.thumbnails.size ??
configDefaults.media_gallery.controls.thumbnails.size;
const columns = this.galleryConfig?.controls.thumbnails.show_details
? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
: Math.max(1, Math.ceil(this.clientWidth / thumbnailSize));
this.style.setProperty('--advanced-camera-card-gallery-columns', String(columns));
}
/**
* Handle gallery resize.
*/
protected _resizeHandler(): void {
this._setColumnCount();
}
protected async _intersectionHandler(
entries: IntersectionObserverEntry[],
): Promise<void> {
if (entries.every((entry) => !entry.isIntersecting)) {
return;
}
this._showLoaderBottom = false;
await this._extendGallery('earlier');
}
protected async _extendGallery(
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.cameraManager || !this.hass || !view) {
return;
}
const query = view.query;
const rawQueries = query?.getQueries() ?? null;
const existingMedia = view.queryResults?.getResults();
if (!query || !rawQueries || !existingMedia) {
return;
}
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
try {
extension = await this.cameraManager.extendMediaQueries<MediaQuery>(
rawQueries,
existingMedia,
direction,
{
useCache: useCache,
},
);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (extension) {
const newMediaQueries = MediaQueriesClassifier.areEventQueries(query)
? new EventMediaQueries(extension.queries as EventQuery[])
: MediaQueriesClassifier.areRecordingQueries(query)
? new RecordingMediaQueries(extension.queries as RecordingQuery[])
: null;
if (newMediaQueries) {
this.viewManagerEpoch?.manager.setViewByParameters({
baseView: view,
params: {
query: newMediaQueries,
queryResults: new MediaQueriesResults({
results: extension.results,
}).selectResultIfFound(
(media) => media === view.queryResults?.getSelectedResult(),
),
},
});
}
}
}
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('galleryConfig')) {
if (this.galleryConfig?.controls.thumbnails.show_details) {
this.setAttribute('details', '');
} else {
this.removeAttribute('details');
}
this._setColumnCount();
if (this.galleryConfig?.controls.thumbnails.size) {
this.style.setProperty(
'--advanced-camera-card-thumbnail-size',
`${this.galleryConfig.controls.thumbnails.size}px`,
);
}
}
if (changedProps.has('viewManagerEpoch')) {
// If the view changes, always render the bottom loader to allow for the
// view to be extended once the bottom loader becomes visible.
this._showLoaderBottom = true;
const view = this.viewManagerEpoch?.manager.getView();
const oldView = this.viewManagerEpoch?.oldView;
if (
!this._media ||
oldView?.queryResults?.getResults() !== view?.queryResults?.getResults()
) {
// 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 = [...(view?.queryResults?.getResults() ?? [])].reverse();
}
}
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this._media || !this.hass) {
return html``;
}
const view = this.viewManagerEpoch?.manager.getView();
if (!view?.queryResults || view.queryResults.getResultsCount() === 0) {
// Note that this is not throwing up an error message for the card to
// handle (as typical), but rather directly rendering the message into the
// gallery. This is to allow the filter to still be available when a given
// filter selection returns no media.
const loadingMedia = !!view?.context?.loading?.query;
return renderMessage({
type: 'info',
message: loadingMedia
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: loadingMedia,
});
}
const selected = view.queryResults.getSelectedResult();
return html` <div class="grid">
${this._showLoaderTop
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
classes: {
top: true,
},
size: 'small',
})}`
: ''}
${this._media.map(
(media, index) =>
html`<advanced-camera-card-thumbnail
${media === selected ? ref(this._refSelected) : ''}
class=${classMap({
selected: media === selected,
})}
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.media=${media}
.viewManagerEpoch=${this.viewManagerEpoch}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control}
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
.show_timeline_control}
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
@click=${(ev: Event) => {
if (this._media) {
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
queryResults: view.queryResults?.clone().selectIndex(
// Media in the gallery is reversed vs the queryResults (see
// note above).
this._media.length - index - 1,
),
},
});
}
stopEventFromActivatingCardWideActions(ev);
}}
>
</advanced-camera-card-thumbnail>`,
)}
${this._showLoaderBottom
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
componentRef: this._refLoaderBottom,
})}`
: ''}
</div>`;
}
public updated(changedProps: PropertyValues): void {
if (this._refLoaderBottom.value) {
this._intersectionObserver.disconnect();
this._intersectionObserver.observe(this._refLoaderBottom.value);
}
// This wait for updateComplete is necessary for the scrolling to work
// correctly.
this.updateComplete.then(() => {
// As a special case, if the view has changed and did not previously exist
// (i.e. first setting of it), we intentionally scroll the gallery to the
// selected element in that view (if any).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
if (
// If this update cycle updated the view ...
changedProps.has('viewManagerEpoch') &&
// ... and it wasn't set at all prior ...
!changedProps.get('viewManagerEpoch') &&
// ... and there is a thumbnail rendered that is selected.
this._refSelected.value
) {
scrollIntoView(this._refSelected.value, {
boundary: this,
block: 'center',
});
}
});
}
static get styles(): CSSResultGroup {
return unsafeCSS(galleryCoreStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-gallery-core': AdvancedCameraCardGalleryCore;
'advanced-camera-card-gallery': AdvancedCameraCardGallery;
}
}
+147
View File
@@ -0,0 +1,147 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import {
getUpFolderMediaItem,
upFolderClickHandler,
} from '../../components-lib/folder/up-folder.js';
import { FolderGalleryController } from '../../components-lib/gallery/folder-gallery-controller.js';
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize';
import folderGalleryStyle from '../../scss/folder-gallery.scss';
import { ViewItem } from '../../view/item.js';
import '../media-filter';
import '../message.js';
import { renderMessage } from '../message.js';
import '../surround-basic';
import '../thumbnail/thumbnail.js';
import './gallery-core.js';
@customElement('advanced-camera-card-folder-gallery')
export class AdvancedCameraCardFolderGallery extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public galleryConfig?: MediaGalleryConfig;
protected _controller = new FolderGalleryController(this);
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('galleryConfig')) {
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
}
}
protected _renderThumbnail(
item: ViewItem,
selected: boolean,
clickCallback: (item: ViewItem, ev: Event) => void,
): TemplateResult | void {
return html`<advanced-camera-card-thumbnail
class=${classMap({
selected,
})}
.hass=${this.hass}
.item=${item}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewItemManager=${this.viewItemManager}
?selected=${selected}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control}
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
.show_timeline_control}
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
@click=${(ev: Event) => clickCallback(item, ev)}
>
</advanced-camera-card-thumbnail>`;
}
protected _renderThumbnails(): TemplateResult | void {
const selected = this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult();
return html`
${this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getResults()
?.map((item) =>
this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => {
const manager = this.viewManagerEpoch?.manager;
if (manager) {
this._controller.itemClickHandler(manager, item, ev);
}
}),
)}
`;
}
protected render(): TemplateResult | void {
const folderIsLoading =
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
const upThumbnail = getUpFolderMediaItem(this.viewManagerEpoch?.manager.getView());
return html`
<advanced-camera-card-surround-basic>
${!this.viewManagerEpoch?.manager.getView()?.queryResults?.hasResults() &&
(folderIsLoading || !upThumbnail)
? renderMessage({
type: 'info',
message: folderIsLoading
? localize('error.awaiting_folder')
: localize('common.no_folder'),
icon: 'mdi:folder-play',
dotdotdot: folderIsLoading,
})
: html`<advanced-camera-card-gallery-core
.hass=${this.hass}
.columnWidth=${this._controller.getColumnWidth(
this.galleryConfig?.controls.thumbnails,
)}
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
this.galleryConfig?.controls.thumbnails,
)}
>
${upThumbnail
? this._renderThumbnail(
upThumbnail,
false,
(item: ViewItem, ev: Event) =>
upFolderClickHandler(item, ev, this.viewManagerEpoch),
)
: ''}
${this._renderThumbnails()}
</advanced-camera-card-gallery-core>`}
</advanced-camera-card-surround-basic>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(folderGalleryStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-folder-gallery': AdvancedCameraCardFolderGallery;
}
}
+119
View File
@@ -0,0 +1,119 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import {
GalleryColumnCountRoundMethod,
GalleryCoreController,
} from '../../components-lib/gallery/gallery-core-controller.js';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { HomeAssistant } from '../../ha/types.js';
import galleryCoreStyle from '../../scss/gallery-core.scss';
import '../message.js';
import '../progress-indicator.js';
import { renderProgressIndicator } from '../progress-indicator.js';
@customElement('advanced-camera-card-gallery-core')
export class AdvancedCameraCardGalleryCore extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public columnWidth: number = THUMBNAIL_WIDTH_DEFAULT;
@property({ attribute: false })
public columnCountRoundMethod?: GalleryColumnCountRoundMethod;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public extendUp = false;
@property({ attribute: false })
public extendDown = false;
private _refSentinelBottom: Ref<HTMLElement> = createRef();
private _refSlot: Ref<HTMLSlotElement> = createRef();
private _controller = new GalleryCoreController(
this,
() => this._refSlot.value ?? null,
() => this._refSentinelBottom.value ?? null,
(show) => {
this._showLoaderTop = show;
},
(show) => {
this._showSentinelBottom = show;
},
);
// Top loader: A progress indicator is shown across the top of the gallery if
// the user is _already_ at the top of the gallery and scrolls upwards. This
// attempts to fetch new content from "later" (more recently) than the current
// query. This is hidden by default.
@state()
private _showLoaderTop = false;
// Bottom sentinel: A progress indicator shown in a "cell" (not across) at the
// bottom of the gallery. Once visible an attempt is optionally made to extend
// the gallery downwards. This is rendered by default (so intersection can be
// detected), and hidden during fetches.
@state()
private _showSentinelBottom = true;
protected willUpdate(changedProps: PropertyValues): void {
if (
['columnCountRoundMethod', 'columnWidth', 'extendUp', 'extendDown'].some((prop) =>
changedProps.has(prop),
)
) {
this._controller.setOptions({
extendUp: this.extendUp,
extendDown: this.extendDown,
columnWidth: this.columnWidth,
columnCountRoundMethod: this.columnCountRoundMethod,
});
}
}
protected render(): TemplateResult | void {
return html` <div class="grid">
${this.extendUp && this._showLoaderTop
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
classes: {
top: true,
},
size: 'small',
})}`
: ''}
<slot ${ref(this._refSlot)} @slotchange=${() => this._controller.updateContents()}>
</slot>
${this.extendDown && this._showSentinelBottom
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
componentRef: this._refSentinelBottom,
size: 'small',
})}`
: ''}
</div>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(galleryCoreStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-gallery-core': AdvancedCameraCardGalleryCore;
}
}
+194
View File
@@ -0,0 +1,194 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaGalleryController } from '../../components-lib/gallery/media-gallery-controller.js';
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize';
import mediaGalleryStyle from '../../scss/media-gallery.scss';
import '../media-filter';
import '../message.js';
import { renderMessage } from '../message.js';
import '../surround-basic';
import '../thumbnail/thumbnail.js';
import './gallery-core.js';
import { GalleryExtendEvent } from './types.js';
const MEDIA_GALLERY_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline',
open: 'mdi:filter-cog',
};
@customElement('advanced-camera-card-media-gallery')
export class AdvancedCameraCardMediaGallery extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: MediaGalleryConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public viewItemManager?: ViewItemManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _controller = new MediaGalleryController(this);
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch')) {
this._controller.setMediaFromView(
this.viewManagerEpoch?.manager.getView(),
this.viewManagerEpoch?.oldView,
);
}
if (changedProps.has('galleryConfig')) {
this._controller.setThumbnailSize(this.galleryConfig?.controls.thumbnails.size);
}
}
protected _renderThumbnails(): TemplateResult | void {
const selected = this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult();
return html`
${this._controller.getMedia()?.map(
(media, index) =>
html`<advanced-camera-card-thumbnail
class=${classMap({
selected: media === selected,
})}
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.viewItemManager=${this.viewItemManager}
.item=${media}
.viewManagerEpoch=${this.viewManagerEpoch}
?selected=${media === selected}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control}
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
.show_timeline_control}
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
@click=${(ev: Event) => {
const manager = this.viewManagerEpoch?.manager;
if (manager) {
this._controller.itemClickHandler(manager, index, ev);
}
}}
>
</advanced-camera-card-thumbnail>`,
)}
`;
}
protected render(): TemplateResult | void {
const mediaIsLoading =
!!this.viewManagerEpoch?.manager.getView()?.context?.loading?.query;
return html`
<advanced-camera-card-surround-basic
.drawerIcons=${{
...(this.galleryConfig &&
this.galleryConfig.controls.filter.mode !== 'none' && {
[this.galleryConfig.controls.filter.mode]: MEDIA_GALLERY_FILTER_MENU_ICONS,
}),
}}
>
${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
? html` <advanced-camera-card-media-filter
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.viewManagerEpoch=${this.viewManagerEpoch}
.cardWideConfig=${this.cardWideConfig}
slot=${this.galleryConfig.controls.filter.mode}
>
</advanced-camera-card-media-filter>`
: ''}
${!this._controller.getMedia()?.length
? renderMessage({
type: 'info',
message: mediaIsLoading
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: mediaIsLoading,
})
: html`<advanced-camera-card-gallery-core
.hass=${this.hass}
.columnWidth=${this._controller.getColumnWidth(
this.galleryConfig?.controls.thumbnails,
)}
.columnCountRoundMethod=${this._controller.getColumnCountRoundMethod(
this.galleryConfig?.controls.thumbnails,
)}
.cardWideConfig=${this.cardWideConfig}
.extendUp=${true}
.extendDown=${true}
@advanced-camera-card:gallery:extend:up=${(
ev: CustomEvent<GalleryExtendEvent>,
) =>
this._extendGallery(
ev,
'later',
// Avoid use of cache since the user is explicitly looking for
// the freshest possible data.
false,
)}
@advanced-camera-card:gallery:extend:down=${(
ev: CustomEvent<GalleryExtendEvent>,
) => this._extendGallery(ev, 'earlier')}
>
${this._renderThumbnails()}
</advanced-camera-card-gallery-core>`}
</advanced-camera-card-surround-basic>
`;
}
protected async _extendGallery(
ev: CustomEvent<GalleryExtendEvent>,
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
if (!this.cameraManager || !this.viewManagerEpoch) {
return;
}
await this._controller.extendMediaGallery(
this.cameraManager,
this.viewManagerEpoch,
direction,
useCache,
);
ev.detail.resolve();
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaGalleryStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-media-gallery': AdvancedCameraCardMediaGallery;
}
}
+3
View File
@@ -0,0 +1,3 @@
export interface GalleryExtendEvent {
resolve: () => void;
}
+8 -3
View File
@@ -4,7 +4,12 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { ImageMediaPlayerController } from '../components-lib/media-player/image';
import imagePlayerStyle from '../scss/image-player.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types';
import {
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
MediaTechnology,
} from '../types';
import { dispatchMediaLoadedEvent } from '../utils/media-info';
/**
@@ -16,7 +21,7 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
public url?: string;
@property()
public filetype?: string;
public technology?: MediaTechnology;
protected _refImage: Ref<MediaPlayerElement<HTMLImageElement>> = createRef();
protected _mediaPlayerController = new ImageMediaPlayerController(
@@ -37,7 +42,7 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
...(this._mediaPlayerController && {
mediaPlayerController: this._mediaPlayerController,
}),
technology: [this.filetype ?? 'jpg'],
technology: [this.technology ?? ('jpg' as const)],
});
}}
/>`;
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more