feat: Make cameras optional (#2343)
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
PTZActionPhase,
|
||||
PTZPanTiltAction,
|
||||
} from '../config/schema/actions/custom/ptz.js';
|
||||
import { CameraConfig, CamerasConfig, Rotation } from '../config/schema/cameras.js';
|
||||
import { CameraConfig, Rotation } from '../config/schema/cameras.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { Endpoint } from '../types.js';
|
||||
@@ -166,7 +166,7 @@ export class CameraManager {
|
||||
// global config (which does have defaults). The merging must happen in this
|
||||
// order, to ensure that the defaults in the cameras global config do not
|
||||
// override the values specified in the per-camera config.
|
||||
const cameras = config.cameras.map((camera) =>
|
||||
const cameras = (config.cameras ?? []).map((camera) =>
|
||||
recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
|
||||
);
|
||||
|
||||
@@ -186,7 +186,7 @@ export class CameraManager {
|
||||
}
|
||||
|
||||
protected async _getEnginesForCameras(
|
||||
camerasConfig: CamerasConfig,
|
||||
camerasConfig: CameraConfig[],
|
||||
): Promise<Map<CameraConfig, CameraManagerEngine>> {
|
||||
const output: Map<CameraConfig, CameraManagerEngine> = new Map();
|
||||
const engines: Map<Engine, CameraManagerEngine> = new Map();
|
||||
@@ -228,7 +228,7 @@ export class CameraManager {
|
||||
return output;
|
||||
}
|
||||
|
||||
protected async _initializeCameras(camerasConfig: CamerasConfig): Promise<void> {
|
||||
protected async _initializeCameras(camerasConfig: CameraConfig[]): Promise<void> {
|
||||
const initializationStartTime = new Date();
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@ interface CapabilitySearchAllAny {
|
||||
}
|
||||
export type CapabilitySearchKeys = CapabilityKey | CapabilitySearchAllAny;
|
||||
export interface CapabilitySearchOptions {
|
||||
// If true, include a parent camera in results when any of its dependent
|
||||
// (child) cameras have the capability, even if the parent itself doesn't.
|
||||
inclusive?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
|
||||
|
||||
switch (this._action.effect_action) {
|
||||
case 'start':
|
||||
api.getEffectsControllerAPI()?.startEffect(this._action.effect);
|
||||
api.getEffectsManager().startEffect(this._action.effect);
|
||||
break;
|
||||
case 'stop':
|
||||
api.getEffectsControllerAPI()?.stopEffect(this._action.effect);
|
||||
api.getEffectsManager().stopEffect(this._action.effect);
|
||||
break;
|
||||
case 'toggle':
|
||||
api.getEffectsControllerAPI()?.toggleEffect(this._action.effect);
|
||||
api.getEffectsManager().toggleEffect(this._action.effect);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
|
||||
const item = view?.queryResults?.getSelectedResult() ?? null;
|
||||
|
||||
if (view?.is('live')) {
|
||||
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
|
||||
const cameraID = getStreamCameraID(view);
|
||||
if (cameraID) {
|
||||
await mediaPlayerController.playLive(mediaPlayer, cameraID);
|
||||
}
|
||||
} else if (view?.isViewerView() && item && ViewItemClassifier.isMedia(item)) {
|
||||
await mediaPlayerController.playMedia(mediaPlayer, item);
|
||||
}
|
||||
|
||||
@@ -30,9 +30,10 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
|
||||
viewManager.getEpoch(),
|
||||
getReviewedQueryFilterFromQuery(view?.query, item),
|
||||
),
|
||||
api
|
||||
.getEffectsControllerAPI()
|
||||
?.startEffect('check', { duration: 0.4, fadeIn: false }),
|
||||
api.getEffectsManager().startEffect('check', {
|
||||
duration: 0.4,
|
||||
fadeIn: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Trigger UI update to refresh menu icon state
|
||||
|
||||
@@ -47,11 +47,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
// When the camera changes, update the entity to match (only if different
|
||||
// to avoid race conditions when multiple cards share the same entity).
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2244
|
||||
createInternalCallbackAction((api: CardActionsAPI) =>
|
||||
createInternalCallbackAction(async (api: CardActionsAPI) =>
|
||||
selectOptionOnEntityIfDifferent(
|
||||
cameraControlEntity,
|
||||
api.getViewManager().getView()?.camera,
|
||||
api,
|
||||
cameraControlEntity,
|
||||
api.getViewManager().getView()?.camera ?? undefined,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -76,13 +76,10 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
`{{ hass.states["${cameraControlEntity}"].state }}`,
|
||||
)
|
||||
: // Set the selected option in the entity to the current camera ID.
|
||||
createInternalCallbackAction((api: CardActionsAPI) =>
|
||||
selectOptionOnEntityIfDifferent(
|
||||
cameraControlEntity,
|
||||
api.getViewManager().getView()?.camera,
|
||||
api,
|
||||
),
|
||||
),
|
||||
createInternalCallbackAction(async (api: CardActionsAPI) => {
|
||||
const camera = api.getViewManager().getView()?.camera ?? undefined;
|
||||
return selectOptionOnEntityIfDifferent(api, cameraControlEntity, camera);
|
||||
}),
|
||||
],
|
||||
tag: automationTag,
|
||||
},
|
||||
@@ -108,9 +105,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
};
|
||||
|
||||
const selectOptionOnEntityIfDifferent = async (
|
||||
entity: string,
|
||||
option: string | undefined,
|
||||
api: CardActionsAPI,
|
||||
entity: string,
|
||||
option?: string,
|
||||
): Promise<void> => {
|
||||
const hass = api.getHASSManager().getHASS();
|
||||
const currentState = hass?.states[entity]?.state;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveController } from 'lit';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { EffectsManager } from './effects/effects-manager';
|
||||
import { ConditionStateManager } from '../conditions/state-manager';
|
||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||
import { DeviceRegistryManager } from '../ha/registry/device';
|
||||
@@ -8,7 +9,6 @@ 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 { EffectsControllerAPI } from '../types';
|
||||
import { ActionsManager } from './actions/actions-manager';
|
||||
import { AutomationsManager } from './automations-manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
@@ -28,7 +28,6 @@ import { InitializationManager } from './initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||
|
||||
import { MediaPlayerManager } from './media-player-manager';
|
||||
import { MessageManager } from './message-manager';
|
||||
import { MicrophoneManager } from './microphone-manager';
|
||||
@@ -67,8 +66,6 @@ import {
|
||||
import { ViewItemManager } from './view/item-manager';
|
||||
import { ViewManager } from './view/view-manager';
|
||||
|
||||
type EffectsControllerAPICallback = () => EffectsControllerAPI | null;
|
||||
|
||||
export class CardController
|
||||
implements
|
||||
CardActionsManagerAPI,
|
||||
@@ -97,9 +94,8 @@ export class CardController
|
||||
CardViewAPI,
|
||||
ReactiveController
|
||||
{
|
||||
protected _effectsControllerAPICallback: EffectsControllerAPICallback;
|
||||
|
||||
protected _conditionStateManager = new ConditionStateManager();
|
||||
protected _effectsManager = new EffectsManager();
|
||||
|
||||
// These properties may be used in the construction of 'managers' (and should
|
||||
// be created first).
|
||||
@@ -138,7 +134,6 @@ export class CardController
|
||||
host: CardHTMLElement,
|
||||
scrollCallback: ScrollCallback,
|
||||
menuToggleCallback: MenuToggleCallback,
|
||||
effectsControllerAPICallback: EffectsControllerAPICallback,
|
||||
) {
|
||||
host.addController(this);
|
||||
|
||||
@@ -148,7 +143,6 @@ export class CardController
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
);
|
||||
this._effectsControllerAPICallback = effectsControllerAPICallback;
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
@@ -199,8 +193,8 @@ export class CardController
|
||||
return this._deviceRegistryManager;
|
||||
}
|
||||
|
||||
public getEffectsControllerAPI(): EffectsControllerAPI | null {
|
||||
return this._effectsControllerAPICallback();
|
||||
public getEffectsManager(): EffectsManager {
|
||||
return this._effectsManager;
|
||||
}
|
||||
|
||||
public getEntityRegistryManager(): EntityRegistryManager {
|
||||
|
||||
+103
-54
@@ -1,4 +1,4 @@
|
||||
import { EffectName, EffectsControllerAPI } from '../../types';
|
||||
import { EffectName, EffectsContainer, EffectsManagerInterface } from '../../types';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { EffectComponent, EffectModule, EffectOptions } from './types';
|
||||
|
||||
@@ -29,38 +29,125 @@ const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = {
|
||||
},
|
||||
};
|
||||
|
||||
type EffectsContainer = HTMLElement | DocumentFragment;
|
||||
type EffectModuleImporter = (name: EffectName) => Promise<EffectModule | null>;
|
||||
|
||||
export class EffectsController implements EffectsControllerAPI {
|
||||
const defaultImportEffectModule: EffectModuleImporter = async (name: EffectName) => {
|
||||
const effectModule = await effectRegistry[name]?.();
|
||||
return effectModule ?? null;
|
||||
};
|
||||
|
||||
export class EffectsManager implements EffectsManagerInterface {
|
||||
private _importer: EffectModuleImporter;
|
||||
private _importedModules: Map<EffectName, EffectModule> = new Map();
|
||||
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
|
||||
private _durationTimers: Map<EffectName, Timer> = new Map();
|
||||
private _container: EffectsContainer | null = null;
|
||||
|
||||
public setContainer(container: EffectsContainer | null): void {
|
||||
// Effects that have been requested to start but are still loading or waiting
|
||||
// for a container to be registered (test case: a card without cameras or
|
||||
// folders will initialize very quickly and the card will be loaded before
|
||||
// effects can be started).
|
||||
private _pendingEffects: Map<EffectName, EffectOptions | undefined> = new Map();
|
||||
private _activeEffects: Map<EffectName, EffectComponent | null> = new Map();
|
||||
protected _container: EffectsContainer | null = null;
|
||||
|
||||
constructor(importer: EffectModuleImporter = defaultImportEffectModule) {
|
||||
this._importer = importer;
|
||||
}
|
||||
|
||||
public setContainer(container: EffectsContainer): void {
|
||||
this._container = container;
|
||||
this._startPendingEffects();
|
||||
}
|
||||
|
||||
public removeContainer(): void {
|
||||
this._container = null;
|
||||
this._pendingEffects.clear();
|
||||
|
||||
for (const timer of this._durationTimers.values()) {
|
||||
timer.stop();
|
||||
}
|
||||
this._durationTimers.clear();
|
||||
|
||||
for (const instance of this._activeEffects.values()) {
|
||||
instance?.remove();
|
||||
}
|
||||
this._activeEffects.clear();
|
||||
}
|
||||
|
||||
public async startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
|
||||
if (!this._container || this._activeInstances.has(name)) {
|
||||
if (this._activeEffects.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve the slot immediately with null to prevent concurrent starts.
|
||||
this._activeInstances.set(name, null);
|
||||
this._activeEffects.set(name, null);
|
||||
if (!this._container) {
|
||||
this._pendingEffects.set(name, options);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._startEffect(name, options);
|
||||
}
|
||||
|
||||
public async stopEffect(effect: EffectName): Promise<void> {
|
||||
const timer = this._durationTimers.get(effect);
|
||||
if (timer) {
|
||||
timer.stop();
|
||||
this._durationTimers.delete(effect);
|
||||
}
|
||||
this._pendingEffects.delete(effect);
|
||||
|
||||
if (!this._activeEffects.has(effect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = this._activeEffects.get(effect);
|
||||
this._activeEffects.delete(effect);
|
||||
|
||||
// If instance is null, it's still loading - just clearing the reservation
|
||||
// will prevent it from appearing (startEffect checks this after import).
|
||||
if (instance) {
|
||||
await instance.startFadeOut();
|
||||
instance.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public async toggleEffect(name: EffectName, options?: EffectOptions): Promise<void> {
|
||||
if (this._activeEffects.has(name)) {
|
||||
await this.stopEffect(name);
|
||||
} else {
|
||||
await this.startEffect(name, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async _importEffectModule(name: EffectName): Promise<EffectModule | null> {
|
||||
const existingModule = this._importedModules.get(name);
|
||||
if (existingModule) {
|
||||
return existingModule;
|
||||
}
|
||||
|
||||
const effectModule = await this._importer(name);
|
||||
if (!effectModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this._importedModules.set(name, effectModule);
|
||||
return effectModule;
|
||||
}
|
||||
|
||||
private async _startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
|
||||
const effectModule = await this._importEffectModule(name);
|
||||
|
||||
// Check if the effect was cancelled during loading.
|
||||
if (!effectModule || !this._activeInstances.has(name)) {
|
||||
this._activeInstances.delete(name);
|
||||
this._pendingEffects.delete(name);
|
||||
|
||||
if (!effectModule || !this._activeEffects.has(name) || !this._container) {
|
||||
this._activeEffects.delete(name);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectComponent = new effectModule.default();
|
||||
effectComponent.fadeIn = options?.fadeIn ?? true;
|
||||
this._container.appendChild(effectComponent);
|
||||
this._activeInstances.set(name, effectComponent);
|
||||
this._activeEffects.set(name, effectComponent);
|
||||
|
||||
const duration = options?.duration;
|
||||
if (duration !== undefined) {
|
||||
@@ -76,48 +163,10 @@ export class EffectsController implements EffectsControllerAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async stopEffect(effect: EffectName): Promise<void> {
|
||||
const timer = this._durationTimers.get(effect);
|
||||
if (timer) {
|
||||
timer.stop();
|
||||
this._durationTimers.delete(effect);
|
||||
private _startPendingEffects(): void {
|
||||
for (const [name, options] of this._pendingEffects.entries()) {
|
||||
this._pendingEffects.delete(name);
|
||||
this._startEffect(name, options);
|
||||
}
|
||||
|
||||
if (!this._activeInstances.has(effect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = this._activeInstances.get(effect);
|
||||
this._activeInstances.delete(effect);
|
||||
|
||||
// If instance is null, it's still loading - just clearing the reservation
|
||||
// will prevent it from appearing (startEffect checks this after import).
|
||||
if (instance) {
|
||||
await instance.startFadeOut();
|
||||
instance.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public async toggleEffect(name: EffectName, options?: EffectOptions): Promise<void> {
|
||||
if (this._activeInstances.has(name)) {
|
||||
await this.stopEffect(name);
|
||||
} else {
|
||||
await this.startEffect(name, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async _importEffectModule(name: EffectName): Promise<EffectModule | null> {
|
||||
const existingModule = this._importedModules.get(name);
|
||||
if (existingModule) {
|
||||
return existingModule;
|
||||
}
|
||||
|
||||
const effectModule = await effectRegistry[name]?.();
|
||||
if (!effectModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this._importedModules.set(name, effectModule);
|
||||
return effectModule;
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
createGeneralAction,
|
||||
createViewAction,
|
||||
} from '../utils/action.js';
|
||||
import { ViewParameters } from '../view/view';
|
||||
import { CardQueryStringAPI } from './types';
|
||||
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
|
||||
import { ViewParametersUserSpecified } from './view/types.js';
|
||||
|
||||
interface QueryStringViewIntent {
|
||||
view?: Partial<ViewParameters> & {
|
||||
view?: ViewParametersUserSpecified & {
|
||||
default?: boolean;
|
||||
substream?: string;
|
||||
};
|
||||
|
||||
@@ -43,8 +43,8 @@ export class StatusBarItemManager {
|
||||
view?: View | null;
|
||||
mediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
}): StatusBarItem[] {
|
||||
const cameraMetadata = options?.view
|
||||
? options?.cameraManager?.getCameraMetadata(options?.view?.camera)
|
||||
const cameraMetadata = options?.view?.camera
|
||||
? options?.cameraManager?.getCameraMetadata(options.view.camera)
|
||||
: null;
|
||||
const engineIcon = cameraMetadata?.engineIcon ?? null;
|
||||
const selectedResult = options?.view?.queryResults?.getSelectedResult();
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Automation } from '../config/schema/automations';
|
||||
import type { DeviceRegistryManager } from '../ha/registry/device';
|
||||
import type { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import type { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import type { EffectsControllerAPI } from '../types';
|
||||
import type { EffectsManagerInterface } from '../types';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
@@ -44,7 +44,7 @@ export interface CardActionsAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getEffectsControllerAPI(): EffectsControllerAPI | null;
|
||||
getEffectsManager(): EffectsManagerInterface;
|
||||
getExpandManager(): ExpandManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
|
||||
+198
-118
@@ -1,15 +1,22 @@
|
||||
import { AdvancedCameraCardView, VIEW_DEFAULT } from '../../config/schema/common/const';
|
||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||
import { ViewDisplayMode } from '../../config/schema/common/display';
|
||||
import { AdvancedCameraCardConfig } from '../../config/schema/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { resolveViewName } from '../../view/utils/resolve-default';
|
||||
import { View, ViewParameters } from '../../view/view';
|
||||
import {
|
||||
getCameraIDsForViewName,
|
||||
doesViewRequireCamera,
|
||||
getCameraIDsWithCapabilityForView,
|
||||
isViewSupportedByCamera,
|
||||
} from '../../view/view-support';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { applyViewModifiers } from './modifiers';
|
||||
import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types';
|
||||
import { ViewFactoryOptions, ViewIncompatible } from './types';
|
||||
|
||||
interface ResolvedViewTarget {
|
||||
viewName: AdvancedCameraCardView;
|
||||
cameraID: string | null;
|
||||
}
|
||||
|
||||
export class ViewFactory {
|
||||
protected _api: CardViewAPI;
|
||||
@@ -24,130 +31,72 @@ export class ViewFactory {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Neither options.baseView.camera nor options.baseView.view are respected
|
||||
// here, since this is the default view / camera.
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1564
|
||||
|
||||
let cameraID: string | null = null;
|
||||
const viewName = options?.params?.view ?? config.view.default;
|
||||
|
||||
if (options?.params?.camera) {
|
||||
cameraID = options.params.camera;
|
||||
} else {
|
||||
const cameraIDs = [
|
||||
...getCameraIDsForViewName(
|
||||
viewName,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
),
|
||||
];
|
||||
|
||||
if (
|
||||
cameraIDs.length &&
|
||||
options?.baseView?.camera &&
|
||||
config.view.default_cycle_camera
|
||||
) {
|
||||
const currentIndex = cameraIDs.indexOf(options.baseView.camera);
|
||||
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
|
||||
cameraID = cameraIDs[targetIndex];
|
||||
} else {
|
||||
cameraID = cameraIDs[0] ?? null;
|
||||
}
|
||||
}
|
||||
const viewName = this._getDefaultViewName(config);
|
||||
|
||||
return this.getViewByParameters({
|
||||
params: {
|
||||
...options?.params,
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
camera: this._getDefaultCameraID(config, viewName, options),
|
||||
},
|
||||
baseView: options?.baseView,
|
||||
});
|
||||
}
|
||||
|
||||
protected _getDefaultViewName = (
|
||||
config: AdvancedCameraCardConfig,
|
||||
): AdvancedCameraCardView =>
|
||||
resolveViewName(
|
||||
config.view.default,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
);
|
||||
|
||||
protected _getDefaultCameraID(
|
||||
config: AdvancedCameraCardConfig,
|
||||
viewName: AdvancedCameraCardView,
|
||||
options?: ViewFactoryOptions,
|
||||
): string | null {
|
||||
if (options?.params?.camera) {
|
||||
return options.params.camera;
|
||||
}
|
||||
|
||||
const cameraIDs = [
|
||||
...getCameraIDsWithCapabilityForView(
|
||||
viewName,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
),
|
||||
];
|
||||
|
||||
if (
|
||||
cameraIDs.length &&
|
||||
options?.baseView?.camera &&
|
||||
config.view.default_cycle_camera
|
||||
) {
|
||||
const currentIndex = cameraIDs.indexOf(options.baseView.camera);
|
||||
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
|
||||
return cameraIDs[targetIndex];
|
||||
}
|
||||
|
||||
return cameraIDs[0] ?? null;
|
||||
}
|
||||
|
||||
public getViewByParameters(options?: ViewFactoryOptions): View | null {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cameraID: string | null =
|
||||
options?.params?.camera ?? options?.baseView?.camera ?? null;
|
||||
let viewName =
|
||||
options?.params?.view ?? options?.baseView?.view ?? config.view.default;
|
||||
|
||||
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
|
||||
|
||||
if (!cameraID || !allCameraIDs.has(cameraID)) {
|
||||
const viewCameraIDs = getCameraIDsForViewName(
|
||||
viewName,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
);
|
||||
|
||||
// Reset to the default camera.
|
||||
cameraID = viewCameraIDs?.keys().next().value ?? null;
|
||||
}
|
||||
|
||||
if (!cameraID) {
|
||||
const camerasToCapabilities = [
|
||||
...this._api.getCameraManager().getStore().getCameras(),
|
||||
].reduce((acc, [cameraID, camera]) => {
|
||||
const capabilities = camera.getCapabilities()?.getRawCapabilities();
|
||||
if (capabilities) {
|
||||
acc[cameraID] = capabilities;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
throw new ViewNoCameraError(localize('error.no_supported_cameras'), {
|
||||
view: viewName,
|
||||
cameras_capabilities: camerasToCapabilities,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isViewSupportedByCamera(
|
||||
viewName,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
cameraID,
|
||||
)
|
||||
) {
|
||||
if (
|
||||
options?.failSafe &&
|
||||
isViewSupportedByCamera(
|
||||
VIEW_DEFAULT,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
cameraID,
|
||||
)
|
||||
) {
|
||||
viewName = VIEW_DEFAULT;
|
||||
} else {
|
||||
const capabilities = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getCamera(cameraID)
|
||||
?.getCapabilities()
|
||||
?.getRawCapabilities();
|
||||
|
||||
throw new ViewIncompatible(localize('error.no_supported_camera'), {
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
...(capabilities && { camera_capabilities: capabilities }),
|
||||
});
|
||||
}
|
||||
}
|
||||
const configuredDisplayMode = this._getDefaultDisplayModeForView(viewName, config);
|
||||
const displayMode =
|
||||
// Prioritize the configured display mode (if present).
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1812
|
||||
(viewName !== options?.baseView?.view ? configuredDisplayMode : null) ??
|
||||
options?.params?.displayMode ??
|
||||
options?.baseView?.displayMode ??
|
||||
configuredDisplayMode ??
|
||||
'single';
|
||||
let viewName = this._resolveViewName(config, options);
|
||||
let cameraID = this._resolveCameraID(viewName, options);
|
||||
({ viewName, cameraID } = this._ensureViewCompatibility(
|
||||
viewName,
|
||||
cameraID,
|
||||
config,
|
||||
options,
|
||||
));
|
||||
const displayMode = this._resolveDisplayMode(viewName, config, options);
|
||||
|
||||
const viewParameters: ViewParameters = {
|
||||
...options?.params,
|
||||
@@ -165,22 +114,153 @@ export class ViewFactory {
|
||||
return view;
|
||||
}
|
||||
|
||||
protected _getDefaultDisplayModeForView(
|
||||
protected _resolveViewName(
|
||||
config: AdvancedCameraCardConfig,
|
||||
options?: ViewFactoryOptions,
|
||||
): AdvancedCameraCardView {
|
||||
if (options?.params?.view !== undefined) {
|
||||
return resolveViewName(
|
||||
options.params.view,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
);
|
||||
}
|
||||
return options?.baseView?.view ?? this._getDefaultViewName(config);
|
||||
}
|
||||
|
||||
protected _resolveCameraID(
|
||||
viewName: AdvancedCameraCardView,
|
||||
options?: ViewFactoryOptions,
|
||||
): string | null {
|
||||
const cameraID = options?.params?.camera ?? options?.baseView?.camera ?? null;
|
||||
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
|
||||
|
||||
if (cameraID && allCameraIDs.has(cameraID)) {
|
||||
return cameraID;
|
||||
}
|
||||
|
||||
const viewCameraIDs = getCameraIDsWithCapabilityForView(
|
||||
viewName,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
);
|
||||
|
||||
return viewCameraIDs?.keys().next().value ?? null;
|
||||
}
|
||||
|
||||
protected _ensureViewCompatibility(
|
||||
viewName: AdvancedCameraCardView,
|
||||
cameraID: string | null,
|
||||
config: AdvancedCameraCardConfig,
|
||||
options?: ViewFactoryOptions,
|
||||
): ResolvedViewTarget {
|
||||
if (!cameraID && doesViewRequireCamera(viewName)) {
|
||||
return this._handleNoCameraForView(viewName, config, options);
|
||||
}
|
||||
|
||||
if (
|
||||
cameraID &&
|
||||
!isViewSupportedByCamera(
|
||||
viewName,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
cameraID,
|
||||
)
|
||||
) {
|
||||
return this._handleUnsupportedView(viewName, cameraID, config, options);
|
||||
}
|
||||
|
||||
return { viewName, cameraID };
|
||||
}
|
||||
|
||||
protected _handleNoCameraForView(
|
||||
viewName: AdvancedCameraCardView,
|
||||
config: AdvancedCameraCardConfig,
|
||||
options?: ViewFactoryOptions,
|
||||
): ResolvedViewTarget {
|
||||
const defaultViewName = this._getDefaultViewName(config);
|
||||
if (options?.failSafe && !doesViewRequireCamera(defaultViewName)) {
|
||||
return { viewName: defaultViewName, cameraID: null };
|
||||
}
|
||||
if (options?.failSafe) {
|
||||
return {
|
||||
viewName: defaultViewName,
|
||||
cameraID: this._api.getCameraManager().getStore().getDefaultCameraID(),
|
||||
};
|
||||
}
|
||||
throw new ViewIncompatible(localize('error.no_supported_cameras'), {
|
||||
view: viewName,
|
||||
camera: null,
|
||||
default_view: defaultViewName,
|
||||
});
|
||||
}
|
||||
|
||||
protected _handleUnsupportedView(
|
||||
viewName: AdvancedCameraCardView,
|
||||
cameraID: string,
|
||||
config: AdvancedCameraCardConfig,
|
||||
options?: ViewFactoryOptions,
|
||||
): ResolvedViewTarget {
|
||||
const defaultViewName = this._getDefaultViewName(config);
|
||||
if (
|
||||
options?.failSafe &&
|
||||
isViewSupportedByCamera(
|
||||
defaultViewName,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
cameraID,
|
||||
)
|
||||
) {
|
||||
return { viewName: defaultViewName, cameraID };
|
||||
}
|
||||
|
||||
const capabilities = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getCamera(cameraID)
|
||||
?.getCapabilities()
|
||||
?.getRawCapabilities();
|
||||
|
||||
throw new ViewIncompatible(localize('error.no_supported_camera'), {
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
default_view: defaultViewName,
|
||||
...(capabilities && { camera_capabilities: capabilities }),
|
||||
});
|
||||
}
|
||||
|
||||
protected _resolveDisplayMode(
|
||||
viewName: AdvancedCameraCardView,
|
||||
config: AdvancedCameraCardConfig,
|
||||
options?: ViewFactoryOptions,
|
||||
): ViewDisplayMode {
|
||||
const configuredDisplayMode = this._getConfiguredDisplayMode(viewName, config);
|
||||
|
||||
// Prioritize the configured display mode (if present).
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1812
|
||||
return (
|
||||
(viewName !== options?.baseView?.view ? configuredDisplayMode : null) ??
|
||||
options?.params?.displayMode ??
|
||||
options?.baseView?.displayMode ??
|
||||
configuredDisplayMode ??
|
||||
'single'
|
||||
);
|
||||
}
|
||||
|
||||
protected _getConfiguredDisplayMode(
|
||||
viewName: AdvancedCameraCardView,
|
||||
config: AdvancedCameraCardConfig,
|
||||
): ViewDisplayMode | null {
|
||||
let mode: ViewDisplayMode | null = null;
|
||||
switch (viewName) {
|
||||
case 'media':
|
||||
case 'clip':
|
||||
case 'recording':
|
||||
case 'snapshot':
|
||||
mode = config.media_viewer.display?.mode ?? null;
|
||||
break;
|
||||
return config.media_viewer.display?.mode ?? null;
|
||||
case 'live':
|
||||
mode = config.live.display?.mode ?? null;
|
||||
break;
|
||||
return config.live.display?.mode ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ export class SubstreamOnViewModifier implements ViewModifier {
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
if (!view.camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies = [
|
||||
...this._api
|
||||
.getCameraManager()
|
||||
@@ -27,6 +31,13 @@ export class SubstreamOnViewModifier implements ViewModifier {
|
||||
}
|
||||
|
||||
const currentOverride = getStreamCameraID(view);
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached, as there is a
|
||||
view.camera guard at the start of this method and getStreamCameraID will
|
||||
always return non-null as long as camera is present -- @preserve */
|
||||
if (!currentOverride) {
|
||||
return;
|
||||
}
|
||||
const currentIndex = dependencies.indexOf(currentOverride);
|
||||
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AdvancedCameraCardError } from '../../types.js';
|
||||
import { ViewItem } from '../../view/item.js';
|
||||
import { QueryResults } from '../../view/query-results.js';
|
||||
|
||||
import { AdvancedCameraCardUserSpecifiedView } from '../../config/schema/common/const.js';
|
||||
import { View, ViewParameters } from '../../view/view.js';
|
||||
|
||||
export interface ViewModifier {
|
||||
@@ -26,12 +27,16 @@ export interface QueryExecutorOptions {
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export type ViewParametersUserSpecified = Partial<Omit<ViewParameters, 'view'>> & {
|
||||
view?: AdvancedCameraCardUserSpecifiedView;
|
||||
};
|
||||
|
||||
export interface ViewFactoryOptions {
|
||||
// An existing view to evolve from.
|
||||
baseView?: View | null;
|
||||
|
||||
// View parameters to set/evolve.
|
||||
params?: Partial<ViewParameters>;
|
||||
params?: ViewParametersUserSpecified;
|
||||
|
||||
// Modifiers to the view once created.
|
||||
modifiers?: ViewModifier[];
|
||||
@@ -70,5 +75,4 @@ export interface ViewManagerInterface {
|
||||
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
|
||||
}
|
||||
|
||||
export class ViewNoCameraError extends AdvancedCameraCardError {}
|
||||
export class ViewIncompatible extends AdvancedCameraCardError {}
|
||||
|
||||
@@ -317,7 +317,7 @@ export class ViewManager implements ViewManagerInterface {
|
||||
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
view: view?.view,
|
||||
camera: view?.camera,
|
||||
camera: view?.camera ?? undefined,
|
||||
displayMode: view?.displayMode ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { View } from '../../view/view';
|
||||
import { doesViewRequireCamera, isViewSupported } from '../../view/view-support';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { MergeContextViewModifier } from './modifiers/merge-context';
|
||||
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
|
||||
@@ -103,7 +104,26 @@ export class ViewQueryExecutor {
|
||||
];
|
||||
};
|
||||
|
||||
const cameraForQuery = view.isGrid() ? undefined : view.camera;
|
||||
// Don't query if the view is not supported (e.g. a camera-based view with
|
||||
// no cameras).
|
||||
if (
|
||||
!isViewSupported(
|
||||
view.view,
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
view.camera,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't query if the view requires a camera, if it's not in grid mode, but
|
||||
// no camera is provided.
|
||||
if (!view.isGrid() && !view.camera && doesViewRequireCamera(view.view)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cameraForQuery = view.isGrid() ? undefined : view.camera ?? undefined;
|
||||
|
||||
const getDefaultQueryModifiers = async () => {
|
||||
const query = builder.buildDefaultCameraQuery(cameraForQuery, {
|
||||
|
||||
+3
-6
@@ -9,7 +9,6 @@ import { actionHandler } from './action-handler-directive.js';
|
||||
import { CardController } from './card-controller/controller';
|
||||
import { MenuButtonController } from './components-lib/menu-button-controller';
|
||||
import './components/effects/effects';
|
||||
import { AdvancedCameraCardEffects } from './components/effects/effects';
|
||||
import './components/elements.js';
|
||||
import { AdvancedCameraCardElements } from './components/elements.js';
|
||||
import './components/loading.js';
|
||||
@@ -96,12 +95,10 @@ class AdvancedCameraCard extends LitElement {
|
||||
// diagnostics starting at the top).
|
||||
() => this._refMain.value?.scroll({ top: 0 }),
|
||||
() => this._refMenu.value?.toggleMenu(),
|
||||
() => this._refEffects.value ?? null,
|
||||
);
|
||||
|
||||
protected _menuButtonController = new MenuButtonController();
|
||||
|
||||
protected _refEffects: Ref<AdvancedCameraCardEffects> = createRef();
|
||||
protected _refElements: Ref<AdvancedCameraCardElements> = createRef();
|
||||
protected _refMain: Ref<HTMLElement> = createRef();
|
||||
protected _refMenu: Ref<AdvancedCameraCardMenu> = createRef();
|
||||
@@ -350,7 +347,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
// ensure the hover menu styling continues to work.
|
||||
return this._renderInDialogIfNecessary(
|
||||
html` <advanced-camera-card-effects
|
||||
${ref(this._refEffects)}
|
||||
.effectsManager=${this._controller.getEffectsManager()}
|
||||
></advanced-camera-card-effects>
|
||||
<ha-card
|
||||
id="ha-card"
|
||||
@@ -387,9 +384,9 @@ class AdvancedCameraCard extends LitElement {
|
||||
.loaded=${this._controller
|
||||
.getInitializationManager()
|
||||
.wasEverInitialized()}
|
||||
.effectsControllerAPI=${this._config?.performance?.features
|
||||
.effectsManager=${this._config?.performance?.features
|
||||
.card_loading_effects !== false
|
||||
? this._controller.getEffectsControllerAPI()
|
||||
? this._controller.getEffectsManager()
|
||||
: undefined}
|
||||
></advanced-camera-card-loading>`
|
||||
: ''}
|
||||
|
||||
@@ -31,8 +31,12 @@ import { isBeingCasted } from '../utils/casting';
|
||||
import { getPTZTarget } from '../utils/ptz';
|
||||
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
import { resolveViewName } from '../view/utils/resolve-default';
|
||||
import { View } from '../view/view';
|
||||
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
|
||||
import {
|
||||
getCameraIDsWithCapabilityForView,
|
||||
isViewSupported,
|
||||
} from '../view/view-support';
|
||||
|
||||
export interface MenuButtonControllerOptions {
|
||||
currentMediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
@@ -112,7 +116,13 @@ export class MenuButtonController {
|
||||
this._getFoldersButton(config, foldersManager, options?.view),
|
||||
|
||||
...this._dynamicMenuButtons.map((button) => ({
|
||||
style: this._getStyleFromActions(config, button, options),
|
||||
style: this._getStyleFromActions(
|
||||
config,
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
button,
|
||||
options,
|
||||
),
|
||||
...button,
|
||||
})),
|
||||
].filter(isTruthy);
|
||||
@@ -175,7 +185,7 @@ export class MenuButtonController {
|
||||
cameraManager: CameraManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
if (!view) {
|
||||
if (!view?.camera) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -184,7 +194,7 @@ export class MenuButtonController {
|
||||
.getAllDependentCameras(view.camera, 'substream');
|
||||
|
||||
if (substreamCameraIDs.size && view.is('live')) {
|
||||
const substreams = [...substreamCameraIDs].filter(
|
||||
const substreams = Array.from(substreamCameraIDs).filter(
|
||||
(cameraID) => cameraID !== view.camera,
|
||||
);
|
||||
const streams = [view.camera, ...substreams];
|
||||
@@ -239,14 +249,13 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('live', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('live', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:cctv',
|
||||
...config.menu.buttons.live,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.live'),
|
||||
style: view.is('live') ? this._getEmphasizedStyle() : {},
|
||||
style: view?.is('live') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createViewAction('live'),
|
||||
}
|
||||
: null;
|
||||
@@ -258,8 +267,7 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('clips', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:filmstrip',
|
||||
...config.menu.buttons.clips,
|
||||
@@ -278,8 +286,7 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('snapshots', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:camera',
|
||||
...config.menu.buttons.snapshots,
|
||||
@@ -298,14 +305,13 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('recordings', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('recordings', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:album',
|
||||
...config.menu.buttons.recordings,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.recordings'),
|
||||
style: view.is('recordings') ? this._getEmphasizedStyle() : {},
|
||||
style: view?.is('recordings') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createViewAction('recordings'),
|
||||
hold_action: createViewAction('recording'),
|
||||
}
|
||||
@@ -318,14 +324,13 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('reviews', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:play-box-edit-outline',
|
||||
...config.menu.buttons.reviews,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.reviews'),
|
||||
style: view.is('reviews') ? this._getEmphasizedStyle() : {},
|
||||
style: view?.is('reviews') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createViewAction('reviews'),
|
||||
hold_action: createViewAction('review'),
|
||||
}
|
||||
@@ -338,14 +343,13 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('gallery', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('gallery', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:play-box-multiple',
|
||||
...config.menu.buttons.gallery,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.menu.buttons.gallery'),
|
||||
style: view.is('gallery') ? this._getEmphasizedStyle() : {},
|
||||
style: view?.is('gallery') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createViewAction('gallery'),
|
||||
hold_action: createViewAction('media'),
|
||||
}
|
||||
@@ -358,8 +362,7 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('image', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('image', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:image',
|
||||
...config.menu.buttons.image,
|
||||
@@ -377,14 +380,13 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('timeline', cameraManager, foldersManager, view.camera)
|
||||
return isViewSupported('timeline', cameraManager, foldersManager, view?.camera)
|
||||
? {
|
||||
icon: 'mdi:chart-gantt',
|
||||
...config.menu.buttons.timeline,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.timeline'),
|
||||
style: view.is('timeline') ? this._getEmphasizedStyle() : {},
|
||||
style: view?.is('timeline') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createViewAction('timeline'),
|
||||
}
|
||||
: null;
|
||||
@@ -479,11 +481,12 @@ export class MenuButtonController {
|
||||
view?: View | null,
|
||||
microphoneManager?: MicrophoneManager | null,
|
||||
): MenuItem | null {
|
||||
if (!view) {
|
||||
const streamCameraID = view ? getStreamCameraID(view) : null;
|
||||
if (!streamCameraID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const capabilities = cameraManager.getCameraCapabilities(getStreamCameraID(view));
|
||||
const capabilities = cameraManager.getCameraCapabilities(streamCameraID);
|
||||
|
||||
if (microphoneManager && capabilities?.has('2-way-audio')) {
|
||||
const unavailable =
|
||||
@@ -557,7 +560,9 @@ export class MenuButtonController {
|
||||
if (!view) {
|
||||
return null;
|
||||
}
|
||||
const selectedCameraConfig = cameraManager.getStore().getCameraConfig(view.camera);
|
||||
const selectedCameraConfig = view.camera
|
||||
? cameraManager.getStore().getCameraConfig(view.camera)
|
||||
: null;
|
||||
if (
|
||||
mediaPlayerController?.hasMediaPlayers() &&
|
||||
(view.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
|
||||
@@ -659,7 +664,7 @@ export class MenuButtonController {
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const viewCameraIDs = view
|
||||
? getCameraIDsForViewName(view.view, cameraManager, foldersManager)
|
||||
? getCameraIDsWithCapabilityForView(view.view, cameraManager, foldersManager)
|
||||
: null;
|
||||
if (
|
||||
view?.supportsMultipleDisplayModes() &&
|
||||
@@ -754,8 +759,8 @@ export class MenuButtonController {
|
||||
foldersManager?: FoldersManager | null,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const folders = [...(foldersManager?.getFolders() ?? [])];
|
||||
if (!folders?.length) {
|
||||
const folders = Array.from(foldersManager?.getFolders() ?? []);
|
||||
if (!foldersManager?.hasFolders()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -823,6 +828,8 @@ export class MenuButtonController {
|
||||
*/
|
||||
protected _getStyleFromActions(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
button: MenuItem,
|
||||
options?: MenuButtonControllerOptions,
|
||||
): StyleInfo {
|
||||
@@ -848,7 +855,9 @@ export class MenuButtonController {
|
||||
),
|
||||
) ||
|
||||
(action.advanced_camera_card_action === 'default' &&
|
||||
options?.view?.is(config.view.default)) ||
|
||||
options?.view?.is(
|
||||
resolveViewName(config.view.default, cameraManager, foldersManager),
|
||||
)) ||
|
||||
(action.advanced_camera_card_action === 'fullscreen' &&
|
||||
!!options?.fullscreenManager?.isInFullscreen()) ||
|
||||
(action.advanced_camera_card_action === 'camera_select' &&
|
||||
|
||||
@@ -458,7 +458,7 @@ export class TimelineController {
|
||||
main: true,
|
||||
},
|
||||
);
|
||||
} else if (panMode === 'seek-in-camera') {
|
||||
} else if (panMode === 'seek-in-camera' && view.camera) {
|
||||
newResults = results
|
||||
.clone()
|
||||
.selectBestResult(
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { LitElement, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { EffectsController } from '../../components-lib/effects/effects-controller';
|
||||
import { EffectOptions } from '../../components-lib/effects/types';
|
||||
import { LitElement, PropertyValues, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { EffectsManager } from '../../card-controller/effects/effects-manager';
|
||||
import effectsStyle from '../../scss/effects.scss';
|
||||
import { EffectName, EffectsControllerAPI } from '../../types';
|
||||
|
||||
@customElement('advanced-camera-card-effects')
|
||||
export class AdvancedCameraCardEffects
|
||||
extends LitElement
|
||||
implements EffectsControllerAPI
|
||||
{
|
||||
protected _controller = new EffectsController();
|
||||
export class AdvancedCameraCardEffects extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public effectsManager?: EffectsManager;
|
||||
|
||||
public async startEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
|
||||
await this._controller.startEffect(effect, options);
|
||||
}
|
||||
|
||||
public stopEffect(effect: EffectName): void {
|
||||
this._controller.stopEffect(effect);
|
||||
}
|
||||
|
||||
public async toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
|
||||
await this._controller.toggleEffect(effect, options);
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (changedProperties.has('effectsManager')) {
|
||||
const previousManager: EffectsManager | undefined =
|
||||
changedProperties.get('effectsManager');
|
||||
previousManager?.removeContainer();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this._controller.setContainer(this.renderRoot);
|
||||
this.effectsManager?.setContainer(this.renderRoot);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this.effectsManager?.removeContainer();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
|
||||
+17
-1
@@ -10,11 +10,13 @@ import { CameraConfig } from '../config/schema/cameras';
|
||||
import { ImageViewConfig } from '../config/schema/image';
|
||||
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import imageStyle from '../scss/image.scss';
|
||||
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js';
|
||||
import './image-updating-player';
|
||||
import { resolveImageMode } from './image-updating-player';
|
||||
import './media-dimensions-container';
|
||||
import { renderMessage } from './message.js';
|
||||
import './zoomer.js';
|
||||
|
||||
@customElement('advanced-camera-card-image')
|
||||
@@ -81,10 +83,24 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.cameraConfig) {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this image mode requires a camera
|
||||
const mode = resolveImageMode({
|
||||
imageConfig: this.imageConfig,
|
||||
cameraConfig: this.cameraConfig,
|
||||
});
|
||||
|
||||
if (mode === 'camera' && !this.cameraConfig) {
|
||||
return renderMessage({
|
||||
type: 'info',
|
||||
message: localize('error.no_camera_for_image'),
|
||||
icon: 'mdi:camera-off',
|
||||
});
|
||||
}
|
||||
|
||||
return this._renderContainer(html`
|
||||
<advanced-camera-card-image-updating-player
|
||||
${ref(this._refImage)}
|
||||
|
||||
@@ -109,7 +109,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
if (!cameraIDs?.size || !view) {
|
||||
if (!cameraIDs?.size || !view?.camera) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
|
||||
@@ -248,6 +248,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
}
|
||||
|
||||
const cameraID = this.viewFilterCameraID ?? view.camera;
|
||||
if (!cameraID) {
|
||||
return {};
|
||||
}
|
||||
const currentIndex = cameraIDs.indexOf(cameraID);
|
||||
|
||||
if (currentIndex < 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import loadingStyle from '../scss/loading.scss';
|
||||
import { EffectName, EffectsControllerAPI } from '../types';
|
||||
import { EffectName, EffectsManagerInterface } from '../types';
|
||||
import { getReleaseVersion } from '../utils/diagnostics';
|
||||
import './icon';
|
||||
|
||||
@@ -33,7 +33,7 @@ const getDateEffect = (): EffectName | null => {
|
||||
@customElement('advanced-camera-card-loading')
|
||||
export class AdvancedCameraCardLoading extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public effectsControllerAPI?: EffectsControllerAPI | null;
|
||||
public effectsManager?: EffectsManagerInterface;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public loaded = false;
|
||||
@@ -60,13 +60,13 @@ export class AdvancedCameraCardLoading extends LitElement {
|
||||
}
|
||||
|
||||
private _startEffect(effect: EffectName): void {
|
||||
this.effectsControllerAPI?.startEffect(effect, { fadeIn: false });
|
||||
this.effectsManager?.startEffect(effect, { fadeIn: false });
|
||||
this._effectName = effect;
|
||||
}
|
||||
|
||||
private _stopEffect(): void {
|
||||
if (this._effectName) {
|
||||
this.effectsControllerAPI?.stopEffect(this._effectName);
|
||||
this.effectsManager?.stopEffect(this._effectName);
|
||||
}
|
||||
this._effectName = null;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
? this.config.media_viewer.controls.timeline
|
||||
: undefined;
|
||||
|
||||
const cameraConfig = view
|
||||
const cameraConfig = view?.camera
|
||||
? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null
|
||||
: null;
|
||||
|
||||
@@ -155,7 +155,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
.viewItemManager=${this.viewItemManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
${!this.hide && view?.is('image') && cameraConfig
|
||||
${!this.hide && view?.is('image')
|
||||
? html` <advanced-camera-card-image
|
||||
.imageConfig=${this.config.image}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
|
||||
@@ -329,7 +329,4 @@ export const cameraConfigSchema = z
|
||||
.default(cameraConfigDefault);
|
||||
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
||||
|
||||
// Avoid using .nonempty() to avoid changing the inferred type
|
||||
// (https://github.com/colinhacks/zod#minmaxlength).
|
||||
export const camerasConfigSchema = cameraConfigSchema.array().min(1);
|
||||
export type CamerasConfig = z.infer<typeof camerasConfigSchema>;
|
||||
export const camerasConfigSchema = cameraConfigSchema.array().optional();
|
||||
|
||||
@@ -8,10 +8,7 @@ export const STATUS_BAR_PRIORITY_MAX = 100;
|
||||
|
||||
export const BUTTON_SIZE_MIN = 20;
|
||||
|
||||
// The default view (may not be supported on all cameras).
|
||||
export const VIEW_DEFAULT = 'live' as const;
|
||||
|
||||
export const VIEWS_USER_SPECIFIED = [
|
||||
const VIEWS = [
|
||||
'diagnostics',
|
||||
'live',
|
||||
'clip',
|
||||
@@ -29,5 +26,7 @@ export const VIEWS_USER_SPECIFIED = [
|
||||
'image',
|
||||
'timeline',
|
||||
] as const;
|
||||
export type AdvancedCameraCardView = (typeof VIEWS)[number];
|
||||
|
||||
export const VIEWS_USER_SPECIFIED = ['auto', ...VIEWS] as const;
|
||||
export type AdvancedCameraCardUserSpecifiedView = (typeof VIEWS_USER_SPECIFIED)[number];
|
||||
export type AdvancedCameraCardView = AdvancedCameraCardUserSpecifiedView | 'diagnostics';
|
||||
|
||||
@@ -13,4 +13,3 @@ export const menuBaseSchema = z.object({
|
||||
icon: z.string().optional(),
|
||||
permanent: z.boolean().default(false).optional(),
|
||||
});
|
||||
export type MenuItemBase = z.infer<typeof menuBaseSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { actionsSchema } from './actions/types';
|
||||
import { VIEW_DEFAULT, VIEWS_USER_SPECIFIED } from './common/const';
|
||||
import { VIEWS_USER_SPECIFIED } from './common/const';
|
||||
|
||||
const keyboardShortcut = z.object({
|
||||
key: z.string(),
|
||||
@@ -46,7 +46,7 @@ export type PTZKeyboardShortcutName =
|
||||
| 'ptz_zoom_out';
|
||||
|
||||
export const viewConfigDefault = {
|
||||
default: VIEW_DEFAULT,
|
||||
default: 'auto' as const,
|
||||
camera_select: 'current' as const,
|
||||
interaction_seconds: 300,
|
||||
default_reset: {
|
||||
|
||||
@@ -428,6 +428,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
|
||||
protected _viewModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'auto', label: localize('config.view.views.auto') },
|
||||
{ value: 'clip', label: localize('config.view.views.clip') },
|
||||
{ value: 'clips', label: localize('config.view.views.clips') },
|
||||
{ value: 'folder', label: localize('config.view.views.folder') },
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"common": {
|
||||
"advanced_camera_card": "Advanced Camera Card",
|
||||
"advanced_camera_card_description": "An Advanced Camera Card",
|
||||
"up": "Up",
|
||||
"folder": "Folder",
|
||||
"live": "Live",
|
||||
"no_folder": "No folder to display",
|
||||
@@ -22,6 +21,7 @@
|
||||
"medium": "Medium"
|
||||
},
|
||||
"severity": "Severity",
|
||||
"up": "Up",
|
||||
"version": "Version"
|
||||
},
|
||||
"config": {
|
||||
@@ -628,6 +628,7 @@
|
||||
"untrigger_seconds": "Seconds after inactive state change to untrigger"
|
||||
},
|
||||
"views": {
|
||||
"auto": "Automatic",
|
||||
"clip": "Most recent clip",
|
||||
"clips": "Clips gallery",
|
||||
"current": "Current view",
|
||||
@@ -738,6 +739,8 @@
|
||||
"no_camera_engine": "Could not determine suitable engine for camera",
|
||||
"no_camera_entity": "Could not find camera entity",
|
||||
"no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers",
|
||||
"no_camera_for_image": "No camera configured for camera image mode",
|
||||
"no_camera_for_live": "No cameras are configured for live view",
|
||||
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
|
||||
"no_camera_or_media_for_timeline": "No camera or media available for timeline",
|
||||
"no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method",
|
||||
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import type { EffectOptions } from './components-lib/effects/types';
|
||||
import type { EffectOptions } from './card-controller/effects/types';
|
||||
import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
|
||||
import { Severity } from './severity';
|
||||
|
||||
@@ -217,8 +217,12 @@ export type EffectName =
|
||||
| 'shamrocks'
|
||||
| 'snow';
|
||||
|
||||
export interface EffectsControllerAPI {
|
||||
export type EffectsContainer = HTMLElement | DocumentFragment;
|
||||
export interface EffectsManagerInterface {
|
||||
startEffect(name: EffectName, options?: EffectOptions): Promise<void>;
|
||||
stopEffect(effect: EffectName): void;
|
||||
toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void>;
|
||||
|
||||
setContainer(container: EffectsContainer): void;
|
||||
removeContainer(): void;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ViewItemClassifier } from '../view/item-classifier';
|
||||
export const findBestMediaTimeIndex = (
|
||||
mediaArray: ViewItem[],
|
||||
targetTime: Date,
|
||||
favorCameraID?: string,
|
||||
favorCameraID?: string | null,
|
||||
): number | null => {
|
||||
let bestMatch:
|
||||
| {
|
||||
|
||||
@@ -28,6 +28,9 @@ export const getPTZTarget = (
|
||||
};
|
||||
} else if (view.is('live')) {
|
||||
const substreamAwareCameraID = getStreamCameraID(view);
|
||||
if (!substreamAwareCameraID) {
|
||||
return null;
|
||||
}
|
||||
let type: PTZType = 'digital';
|
||||
|
||||
if (options?.type !== 'digital' && options?.cameraManager) {
|
||||
|
||||
@@ -28,14 +28,14 @@ const screenshotElement = (
|
||||
|
||||
export const generateScreenshotFilename = (view?: View | null): string => {
|
||||
if (view?.is('live') || view?.is('image')) {
|
||||
return `${view.view}_${view.camera}_${format(
|
||||
return `${view.view}${view.camera ? `_${view.camera}` : ''}_${format(
|
||||
new Date(),
|
||||
`yyyy-MM-dd-HH-mm-ss`,
|
||||
)}.jpg`;
|
||||
} else if (view?.isViewerView()) {
|
||||
const media = view.queryResults?.getSelectedResult();
|
||||
const id = media?.getID() ?? null;
|
||||
return `${view.view}_${view.camera}${id ? `_${id}` : ''}.jpg`;
|
||||
return `${view.view}${view.camera ? `_${view.camera}` : ''}${id ? `_${id}` : ''}.jpg`;
|
||||
}
|
||||
return 'screenshot.jpg';
|
||||
};
|
||||
|
||||
+22
-6
@@ -1,18 +1,31 @@
|
||||
import { View } from '../view/view';
|
||||
|
||||
export const getStreamCameraID = (view: View, cameraID?: string): string => {
|
||||
return (
|
||||
view.context?.live?.overrides?.get(cameraID ?? view.camera) ??
|
||||
cameraID ??
|
||||
view.camera
|
||||
);
|
||||
/**
|
||||
* Get the effective camera ID for streaming, considering substream overrides.
|
||||
* Returns null if the view has no camera.
|
||||
*/
|
||||
export const getStreamCameraID = (
|
||||
view: View,
|
||||
cameraID?: string | null,
|
||||
): string | null => {
|
||||
const baseCameraID = cameraID ?? view.camera;
|
||||
if (!baseCameraID) {
|
||||
return null;
|
||||
}
|
||||
return view.context?.live?.overrides?.get(baseCameraID) ?? baseCameraID;
|
||||
};
|
||||
|
||||
export const hasSubstream = (view: View): boolean => {
|
||||
if (!view.camera) {
|
||||
return false;
|
||||
}
|
||||
return getStreamCameraID(view) !== view.camera;
|
||||
};
|
||||
|
||||
export const setSubstream = (view: View, substreamID: string): void => {
|
||||
if (!view.camera) {
|
||||
return;
|
||||
}
|
||||
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
|
||||
overrides.set(view.camera, substreamID);
|
||||
view.mergeInContext({
|
||||
@@ -21,6 +34,9 @@ export const setSubstream = (view: View, substreamID: string): void => {
|
||||
};
|
||||
|
||||
export const removeSubstream = (view: View): void => {
|
||||
if (!view.camera) {
|
||||
return;
|
||||
}
|
||||
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
|
||||
if (overrides && overrides.has(view.camera)) {
|
||||
view.context?.live?.overrides?.delete(view.camera);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import {
|
||||
AdvancedCameraCardUserSpecifiedView,
|
||||
AdvancedCameraCardView,
|
||||
} from '../../config/schema/common/const';
|
||||
|
||||
/**
|
||||
* Resolve a view name that may be 'auto'.
|
||||
* @param viewName The view name.
|
||||
* @param cameraManager The camera manager.
|
||||
* @param foldersManager The folders manager.
|
||||
* @returns A concrete view name.
|
||||
*/
|
||||
export const resolveViewName = (
|
||||
viewName: AdvancedCameraCardUserSpecifiedView,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
): AdvancedCameraCardView => {
|
||||
if (viewName !== 'auto') {
|
||||
return viewName;
|
||||
}
|
||||
return cameraManager.getStore().getCameraIDs().size
|
||||
? 'live'
|
||||
: foldersManager.hasFolders()
|
||||
? 'folders'
|
||||
: 'image';
|
||||
};
|
||||
+125
-51
@@ -1,64 +1,120 @@
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { CapabilitySearchOptions } from '../camera-manager/types';
|
||||
import { CapabilitySearchKeys, CapabilitySearchOptions } from '../camera-manager/types';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
||||
|
||||
/**
|
||||
* Get cameraIDs that are relevant for a given view name based on camera
|
||||
* capability (if camera specified).
|
||||
*/
|
||||
export const getCameraIDsForViewName = (
|
||||
type ViewSource = 'camera' | 'folder' | 'any';
|
||||
|
||||
interface ViewCapabilityRequirements {
|
||||
src?: ViewSource;
|
||||
mediaCapabilities?: CapabilitySearchKeys;
|
||||
mediaCapabilitiesInclusive?: boolean;
|
||||
}
|
||||
|
||||
const anyMedia: ViewCapabilityRequirements = {
|
||||
src: 'any',
|
||||
mediaCapabilities: {
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'],
|
||||
},
|
||||
mediaCapabilitiesInclusive: false,
|
||||
};
|
||||
const anyFolder = { src: 'folder' as const };
|
||||
const noRequirements = {};
|
||||
|
||||
const generateCameraRequirements = (
|
||||
mediaCapabilities: CapabilitySearchKeys,
|
||||
mediaCapabilitiesInclusive = true,
|
||||
): ViewCapabilityRequirements => {
|
||||
return {
|
||||
src: 'camera',
|
||||
mediaCapabilities,
|
||||
mediaCapabilitiesInclusive,
|
||||
};
|
||||
};
|
||||
|
||||
const VIEW_REQUIREMENTS: Record<AdvancedCameraCardView, ViewCapabilityRequirements> = {
|
||||
live: generateCameraRequirements('live', false),
|
||||
|
||||
clip: generateCameraRequirements('clips'),
|
||||
clips: generateCameraRequirements('clips'),
|
||||
snapshot: generateCameraRequirements('snapshots'),
|
||||
snapshots: generateCameraRequirements('snapshots'),
|
||||
recording: generateCameraRequirements('recordings'),
|
||||
recordings: generateCameraRequirements('recordings'),
|
||||
review: generateCameraRequirements('reviews'),
|
||||
reviews: generateCameraRequirements('reviews'),
|
||||
|
||||
gallery: anyMedia,
|
||||
media: anyMedia,
|
||||
timeline: anyMedia,
|
||||
|
||||
folder: anyFolder,
|
||||
folders: anyFolder,
|
||||
|
||||
image: noRequirements,
|
||||
diagnostics: noRequirements,
|
||||
};
|
||||
|
||||
export const isViewAvailable = (
|
||||
view: AdvancedCameraCardView,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
): boolean => {
|
||||
const req = VIEW_REQUIREMENTS[view];
|
||||
const hasCameras = cameraManager.getStore().getCameraIDs().size > 0;
|
||||
const hasFolders = foldersManager.hasFolders();
|
||||
|
||||
if (req.src === 'camera') {
|
||||
return hasCameras;
|
||||
}
|
||||
if (req.src === 'folder') {
|
||||
return hasFolders;
|
||||
}
|
||||
if (req.src === 'any') {
|
||||
return hasCameras || hasFolders;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const doesViewRequireCamera = (view: AdvancedCameraCardView): boolean => {
|
||||
return VIEW_REQUIREMENTS[view].src === 'camera';
|
||||
};
|
||||
|
||||
export const getCameraIDsWithCapabilityForView = (
|
||||
viewName: AdvancedCameraCardView,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
cameraID?: string,
|
||||
): Set<string> => {
|
||||
const folder = foldersManager.getFolder();
|
||||
const requirements = VIEW_REQUIREMENTS[viewName];
|
||||
const allCameras = cameraManager.getStore().getCameraIDs();
|
||||
|
||||
switch (viewName) {
|
||||
case 'diagnostics':
|
||||
case 'image':
|
||||
return cameraManager.getStore().getCameraIDs();
|
||||
|
||||
case 'folder':
|
||||
case 'folders':
|
||||
return folder ? cameraManager.getStore().getCameraIDs() : new Set();
|
||||
|
||||
case 'live':
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
case 'review':
|
||||
case 'reviews':
|
||||
const options: CapabilitySearchOptions = {
|
||||
inclusive: viewName !== 'live',
|
||||
};
|
||||
const capability =
|
||||
viewName === 'clip'
|
||||
? 'clips'
|
||||
: viewName === 'snapshot'
|
||||
? 'snapshots'
|
||||
: viewName === 'recording'
|
||||
? 'recordings'
|
||||
: viewName === 'review'
|
||||
? 'reviews'
|
||||
: viewName;
|
||||
return cameraID
|
||||
? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
|
||||
: cameraManager.getStore().getCameraIDsWithCapability(capability, options);
|
||||
|
||||
case 'gallery':
|
||||
case 'media':
|
||||
case 'timeline':
|
||||
return folder
|
||||
? cameraManager.getStore().getCameraIDs()
|
||||
: cameraManager.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'],
|
||||
});
|
||||
if (requirements.src !== 'camera' && requirements.src !== 'any') {
|
||||
if (requirements.src === 'folder' && !foldersManager.hasFolders()) {
|
||||
return new Set();
|
||||
}
|
||||
return allCameras;
|
||||
}
|
||||
|
||||
if (requirements.src === 'any' && foldersManager.hasFolders()) {
|
||||
return allCameras;
|
||||
}
|
||||
|
||||
const options: CapabilitySearchOptions = {
|
||||
inclusive: !!requirements.mediaCapabilitiesInclusive,
|
||||
};
|
||||
const capability = requirements.mediaCapabilities;
|
||||
|
||||
/* istanbul ignore next: this path is currently unreachable given the mapping
|
||||
in VIEW_REQUIREMENTS includes mediaCapabilities for all camera or 'any'
|
||||
related views -- @preserve */
|
||||
if (!capability) {
|
||||
return allCameras;
|
||||
}
|
||||
|
||||
return cameraID
|
||||
? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
|
||||
: cameraManager.getStore().getCameraIDsWithCapability(capability, options);
|
||||
};
|
||||
|
||||
export const isViewSupportedByCamera = (
|
||||
@@ -67,5 +123,23 @@ export const isViewSupportedByCamera = (
|
||||
foldersManager: FoldersManager,
|
||||
cameraID: string,
|
||||
): boolean => {
|
||||
return !!getCameraIDsForViewName(view, cameraManager, foldersManager, cameraID)?.size;
|
||||
return !!getCameraIDsWithCapabilityForView(
|
||||
view,
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
cameraID,
|
||||
)?.size;
|
||||
};
|
||||
|
||||
export const isViewSupported = (
|
||||
viewName: AdvancedCameraCardView,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
cameraID?: string | null,
|
||||
): boolean => {
|
||||
return (
|
||||
isViewAvailable(viewName, cameraManager, foldersManager) &&
|
||||
(!cameraID ||
|
||||
isViewSupportedByCamera(viewName, cameraManager, foldersManager, cameraID))
|
||||
);
|
||||
};
|
||||
|
||||
+3
-4
@@ -16,7 +16,7 @@ declare module 'view' {
|
||||
|
||||
interface ViewEvolveParameters {
|
||||
view?: AdvancedCameraCardView;
|
||||
camera?: string;
|
||||
camera?: string | null;
|
||||
query?: UnifiedQuery | null;
|
||||
queryResults?: QueryResults | null;
|
||||
context?: ViewContext | null;
|
||||
@@ -25,7 +25,6 @@ interface ViewEvolveParameters {
|
||||
|
||||
export interface ViewParameters extends ViewEvolveParameters {
|
||||
view: AdvancedCameraCardView;
|
||||
camera: string;
|
||||
}
|
||||
|
||||
export const mergeViewContext = (
|
||||
@@ -37,7 +36,7 @@ export const mergeViewContext = (
|
||||
|
||||
export class View {
|
||||
public view: AdvancedCameraCardView;
|
||||
public camera: string;
|
||||
public camera: string | null;
|
||||
public query: UnifiedQuery | null;
|
||||
public queryResults: QueryResults | null;
|
||||
public context: ViewContext | null;
|
||||
@@ -45,7 +44,7 @@ export class View {
|
||||
|
||||
constructor(params: ViewParameters) {
|
||||
this.view = params.view;
|
||||
this.camera = params.camera;
|
||||
this.camera = params.camera ?? null;
|
||||
this.query = params.query ?? null;
|
||||
this.queryResults = params.queryResults ?? null;
|
||||
this.context = params.context ?? null;
|
||||
|
||||
Reference in New Issue
Block a user