feat: Add festive visual effects (#2252)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduces an effects system (fireworks, ghost, hearts, shamrocks, snow), a new `effect` action, and optional date-based loading effects, integrated into the card with docs, schema, editor, and tests. > > - **Effects System**: > - Add `EffectsController` and `advanced-camera-card-effects` host with lazy-loaded effect modules (`fireworks`, `ghost`, `hearts`, `shamrocks`, `snow`). > - New base effect component with fade-in/out; SCSS and z-index updates. > - **Actions**: > - New custom action `advanced_camera_card_action: effect` with `effect_action` (`start`|`stop`|`toggle`). > - Wire into `ActionFactory` and add `EffectAction` executor. > - **Card Integration**: > - Mount effects host in `advanced-camera-card` and expose `getEffectsControllerAPI()` via `CardController`. > - Loading component can trigger date-based effects (e.g., New Year fireworks) when enabled. > - **Configuration & Editor**: > - Add `performance.features.card_loading_effects` (default `true`) alongside `card_loading_indicator`. > - Update schemas, defaults, low-performance profile (disables effects), and editor toggles. > - **Docs & Examples**: > - Document `effect` action parameters and add menu example. > - Update performance docs with new option. > - **Localization & Tests**: > - Update i18n strings for new performance option. > - Add comprehensive tests for effects controller, action, factory, config defaults/profiles, and utilities. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cffd4304fe3bd22340316f9cec53e341379b1756. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -94,6 +94,23 @@ advanced_camera_card_action: display_mode_select
|
||||
| `advanced_camera_card_action` | Must be `display_mode_select`. |
|
||||
| `display_mode` | `single` to show a single camera at a time in a carousel, or `grid` to show a grid of cameras. |
|
||||
|
||||
## `effect`
|
||||
|
||||
Trigger a visual effect.
|
||||
|
||||
```yaml
|
||||
action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: effect
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `action` | Must be `custom:advanced-camera-card-action`. |
|
||||
| `advanced_camera_card_action` | Must be `effect`. |
|
||||
| `effect` | The name of the effect, one of [`fireworks`, `ghost`, `hearts`, `shamrocks`, `snow`]. |
|
||||
| `effect_action` | One of `start`, `stop` or `toggle`. |
|
||||
|
||||
## `download`
|
||||
|
||||
Download the displayed media.
|
||||
@@ -869,4 +886,12 @@ elements:
|
||||
tap_action:
|
||||
- action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: reload
|
||||
- type: custom:advanced-camera-card-menu-icon
|
||||
icon: mdi:snow
|
||||
title: Snow Effect
|
||||
tap_action:
|
||||
- action: custom:advanced-camera-card-action
|
||||
advanced_camera_card_action: effect
|
||||
effect: snow
|
||||
effect_action: toggle
|
||||
```
|
||||
|
||||
@@ -25,7 +25,8 @@ performance:
|
||||
| Option | Default | Description |
|
||||
| ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `animated_progress_indicator` | `true` | Will show the animated progress indicator 'spinners' when `true`. |
|
||||
| `card_loading_indicator` | `true` | Will show the card loading indicator (spinner & version number) when `true`. |
|
||||
| `card_loading_effects` | `true` | Will occasionally show (silly) visual effects during card loading (e.g. fireworks on New Year's Day). |
|
||||
| `card_loading_indicator` | `true` | Will show the card loading indicator (spinner & version number) and effects (see `card_loading_effects` below) when `true`. |
|
||||
| `media_chunk_size` | `50` | How many media items to fetch and render at a time (e.g. thumbnails under a live view, or number of snapshots to load in the media viewer). This may only make partial sense in some contexts (e.g. the 'infinite gallery' is still infinite, it just loads thumbnails this many items at a time) or not at all (e.g. the timeline will show the number of events dictated by the time span the user navigates to). |
|
||||
| `max_simultaneous_engine_requests` | _Infinity_ | How many camera engine requests to allow occur in parallel. Setting lower values will slow the card down since more requests will run in sequence, but it will increase the chances of positive cache hit rates and reduce the chances of overwhelming the backend. |
|
||||
|
||||
@@ -60,6 +61,7 @@ For low end devices, the `low-performance` profile will adjust card defaults to
|
||||
performance:
|
||||
features:
|
||||
animated_progress_indicator: true
|
||||
card_loading_effects: true
|
||||
card_loading_indicator: true
|
||||
media_chunk_size: 50
|
||||
max_simultaneous_engine_requests: 100
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { EffectActionConfig } from '../../../config/schema/actions/custom/effect';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
switch (this._action.effect_action) {
|
||||
case 'start':
|
||||
api.getEffectsControllerAPI()?.startEffect(this._action.effect);
|
||||
break;
|
||||
case 'stop':
|
||||
api.getEffectsControllerAPI()?.stopEffect(this._action.effect);
|
||||
break;
|
||||
case 'toggle':
|
||||
api.getEffectsControllerAPI()?.toggleEffect(this._action.effect);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { EffectAction } from './actions/effect';
|
||||
import { FullscreenAction } from './actions/fullscreen';
|
||||
import { InternalCallbackAction } from './actions/internal-callback';
|
||||
import { LogAction } from './actions/log';
|
||||
@@ -103,6 +104,8 @@ export class ActionFactory {
|
||||
return new DownloadAction(context, action, options?.config);
|
||||
case 'camera_ui':
|
||||
return new CameraUIAction(context, action, options?.config);
|
||||
case 'effect':
|
||||
return new EffectAction(context, action, options?.config);
|
||||
case 'expand':
|
||||
return new ExpandAction(context, action, options?.config);
|
||||
case 'fullscreen':
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -63,6 +64,8 @@ import {
|
||||
import { ViewItemManager } from './view/item-manager';
|
||||
import { ViewManager } from './view/view-manager';
|
||||
|
||||
type EffectsControllerAPICallback = () => EffectsControllerAPI | null;
|
||||
|
||||
export class CardController
|
||||
implements
|
||||
CardActionsManagerAPI,
|
||||
@@ -90,6 +93,8 @@ export class CardController
|
||||
CardViewAPI,
|
||||
ReactiveController
|
||||
{
|
||||
protected _effectsControllerAPICallback: EffectsControllerAPICallback;
|
||||
|
||||
protected _conditionStateManager = new ConditionStateManager();
|
||||
|
||||
// These properties may be used in the construction of 'managers' (and should
|
||||
@@ -127,6 +132,7 @@ export class CardController
|
||||
host: CardHTMLElement,
|
||||
scrollCallback: ScrollCallback,
|
||||
menuToggleCallback: MenuToggleCallback,
|
||||
effectsControllerAPICallback: EffectsControllerAPICallback,
|
||||
) {
|
||||
host.addController(this);
|
||||
|
||||
@@ -136,6 +142,7 @@ export class CardController
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
);
|
||||
this._effectsControllerAPICallback = effectsControllerAPICallback;
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
@@ -186,6 +193,10 @@ export class CardController
|
||||
return this._deviceRegistryManager;
|
||||
}
|
||||
|
||||
public getEffectsControllerAPI(): EffectsControllerAPI | null {
|
||||
return this._effectsControllerAPICallback();
|
||||
}
|
||||
|
||||
public getEntityRegistryManager(): EntityRegistryManager {
|
||||
return this._entityRegistryManager;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ConditionStateManager } from '../conditions/state-manager';
|
||||
import type { Automation } from '../config/schema/automations';
|
||||
import type { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import type { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import type { EffectsControllerAPI } from '../types';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
@@ -41,6 +42,7 @@ export interface CardActionsAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getEffectsControllerAPI(): EffectsControllerAPI | null;
|
||||
getExpandManager(): ExpandManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
|
||||
+110
-97
@@ -8,6 +8,8 @@ import 'web-dialog';
|
||||
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';
|
||||
@@ -93,14 +95,16 @@ 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();
|
||||
protected _refOverlay: Ref<AdvancedCameraCardOverlay> = createRef();
|
||||
protected _refMain: Ref<HTMLElement> = createRef();
|
||||
protected _refElements: Ref<AdvancedCameraCardElements> = createRef();
|
||||
protected _refViews: Ref<AdvancedCameraCardViews> = createRef();
|
||||
|
||||
// Convenience methods for very frequently accessed attributes.
|
||||
@@ -344,104 +348,113 @@ class AdvancedCameraCard extends LitElement {
|
||||
// Caution: Keep the main div and the menu next to one another in order to
|
||||
// ensure the hover menu styling continues to work.
|
||||
return this._renderInDialogIfNecessary(
|
||||
html` <ha-card
|
||||
id="ha-card"
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(actions.hold_action),
|
||||
hasDoubleClick: hasAction(actions.double_tap_action),
|
||||
})}
|
||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||
@advanced-camera-card:media:unloaded=${() =>
|
||||
this._controller.getMediaLoadedInfoManager().clear()}
|
||||
@advanced-camera-card:media:volumechange=${
|
||||
() => this.requestUpdate() /* Refresh mute menu button */
|
||||
}
|
||||
@advanced-camera-card:media:play=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:media:pause=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:focus=${() => this.focus()}
|
||||
>
|
||||
${showLoading
|
||||
? html`<advanced-camera-card-loading
|
||||
?loaded=${this._controller.getInitializationManager().wasEverInitialized()}
|
||||
></advanced-camera-card-loading>`
|
||||
: ''}
|
||||
${this._renderMenuStatusContainer('top')}
|
||||
${this._renderMenuStatusContainer('overlay')}
|
||||
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
|
||||
<advanced-camera-card-views
|
||||
${ref(this._refViews)}
|
||||
.hass=${this._hass}
|
||||
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||
.cameraManager=${cameraManager}
|
||||
.foldersManager=${this._controller.getFoldersManager()}
|
||||
.viewItemManager=${this._controller.getViewItemManager()}
|
||||
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
||||
.config=${this._controller.getConfigManager().getConfig()}
|
||||
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
|
||||
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
|
||||
.configManager=${this._controller.getConfigManager()}
|
||||
.hide=${!!this._controller.getMessageManager().hasMessage()}
|
||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||
: undefined}
|
||||
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
|
||||
></advanced-camera-card-views>
|
||||
${this._controller.getMessageManager().hasMessage()
|
||||
? // Keep message rendering to last to show messages that may have been
|
||||
// generated during the render.
|
||||
renderMessage(this._controller.getMessageManager().getMessage())
|
||||
html` <advanced-camera-card-effects
|
||||
${ref(this._refEffects)}
|
||||
></advanced-camera-card-effects>
|
||||
<ha-card
|
||||
id="ha-card"
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(actions.hold_action),
|
||||
hasDoubleClick: hasAction(actions.double_tap_action),
|
||||
})}
|
||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||
@advanced-camera-card:media:unloaded=${() =>
|
||||
this._controller.getMediaLoadedInfoManager().clear()}
|
||||
@advanced-camera-card:media:volumechange=${
|
||||
() => this.requestUpdate() /* Refresh mute menu button */
|
||||
}
|
||||
@advanced-camera-card:media:play=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:media:pause=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:focus=${() => this.focus()}
|
||||
>
|
||||
${showLoading
|
||||
? html`<advanced-camera-card-loading
|
||||
.loaded=${this._controller
|
||||
.getInitializationManager()
|
||||
.wasEverInitialized()}
|
||||
.effectsControllerAPI=${this._config?.performance?.features
|
||||
.card_loading_effects !== false
|
||||
? this._controller.getEffectsControllerAPI()
|
||||
: undefined}
|
||||
></advanced-camera-card-loading>`
|
||||
: ''}
|
||||
</div>
|
||||
${this._renderMenuStatusContainer('bottom')}
|
||||
${this._config?.elements
|
||||
? // Elements need to render after the main views so it can render 'on
|
||||
// top'.
|
||||
html` <advanced-camera-card-elements
|
||||
${ref(this._refElements)}
|
||||
${this._renderMenuStatusContainer('top')}
|
||||
${this._renderMenuStatusContainer('overlay')}
|
||||
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
|
||||
<advanced-camera-card-views
|
||||
${ref(this._refViews)}
|
||||
.hass=${this._hass}
|
||||
.elements=${this._config?.elements}
|
||||
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||
.cameraManager=${cameraManager}
|
||||
.foldersManager=${this._controller.getFoldersManager()}
|
||||
.viewItemManager=${this._controller.getViewItemManager()}
|
||||
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
||||
.config=${this._controller.getConfigManager().getConfig()}
|
||||
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
|
||||
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
|
||||
.configManager=${this._controller.getConfigManager()}
|
||||
.hide=${!!this._controller.getMessageManager().hasMessage()}
|
||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.addDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.removeDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:status-bar:add=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.addDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:status-bar:remove=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.removeDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:condition-state-manager:get=${(
|
||||
ev: ConditionStateManagerGetEvent,
|
||||
) => {
|
||||
ev.conditionStateManager = this._controller.getConditionStateManager();
|
||||
}}
|
||||
>
|
||||
</advanced-camera-card-elements>`
|
||||
: ``}
|
||||
</ha-card>`,
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||
: undefined}
|
||||
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
|
||||
></advanced-camera-card-views>
|
||||
${this._controller.getMessageManager().hasMessage()
|
||||
? // Keep message rendering to last to show messages that may have been
|
||||
// generated during the render.
|
||||
renderMessage(this._controller.getMessageManager().getMessage())
|
||||
: ''}
|
||||
</div>
|
||||
${this._renderMenuStatusContainer('bottom')}
|
||||
${this._config?.elements
|
||||
? // Elements need to render after the main views so it can render 'on
|
||||
// top'.
|
||||
html` <advanced-camera-card-elements
|
||||
${ref(this._refElements)}
|
||||
.hass=${this._hass}
|
||||
.elements=${this._config?.elements}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.addDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.removeDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:status-bar:add=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.addDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:status-bar:remove=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.removeDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:condition-state-manager:get=${(
|
||||
ev: ConditionStateManagerGetEvent,
|
||||
) => {
|
||||
ev.conditionStateManager = this._controller.getConditionStateManager();
|
||||
}}
|
||||
>
|
||||
</advanced-camera-card-elements>`
|
||||
: ``}
|
||||
</ha-card>`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EffectName, EffectsControllerAPI } from '../../types';
|
||||
import { EffectComponent, EffectModule, EffectOptions } from './types';
|
||||
|
||||
const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = {
|
||||
fireworks: async () => {
|
||||
const module = await import('../../components/effects/fireworks');
|
||||
return { default: module.AdvancedCameraCardEffectFireworks };
|
||||
},
|
||||
ghost: async () => {
|
||||
const module = await import('../../components/effects/ghost');
|
||||
return { default: module.AdvancedCameraCardEffectGhost };
|
||||
},
|
||||
hearts: async () => {
|
||||
const module = await import('../../components/effects/hearts');
|
||||
return { default: module.AdvancedCameraCardEffectHearts };
|
||||
},
|
||||
shamrocks: async () => {
|
||||
const module = await import('../../components/effects/shamrocks');
|
||||
return { default: module.AdvancedCameraCardEffectShamrocks };
|
||||
},
|
||||
snow: async () => {
|
||||
const module = await import('../../components/effects/snow');
|
||||
return { default: module.AdvancedCameraCardEffectSnow };
|
||||
},
|
||||
};
|
||||
|
||||
type EffectsContainer = HTMLElement | DocumentFragment;
|
||||
|
||||
export class EffectsController implements EffectsControllerAPI {
|
||||
private _importedModules: Map<EffectName, EffectModule> = new Map();
|
||||
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
|
||||
private _container: EffectsContainer | null = null;
|
||||
|
||||
public setContainer(container: EffectsContainer | null): void {
|
||||
this._container = container;
|
||||
}
|
||||
|
||||
public async startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
|
||||
if (!this._container || this._activeInstances.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve the slot immediately with null to prevent concurrent starts.
|
||||
this._activeInstances.set(name, null);
|
||||
|
||||
const effectModule = await this._importEffectModule(name);
|
||||
|
||||
// Check if the effect was cancelled during loading.
|
||||
if (!effectModule || !this._activeInstances.has(name)) {
|
||||
this._activeInstances.delete(name);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectComponent = new effectModule.default();
|
||||
effectComponent.fadeIn = options?.fadeIn ?? true;
|
||||
this._container.appendChild(effectComponent);
|
||||
this._activeInstances.set(name, effectComponent);
|
||||
}
|
||||
|
||||
public async stopEffect(effect: EffectName): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type EffectComponent = HTMLElement & {
|
||||
fadeIn: boolean;
|
||||
startFadeOut(): Promise<void>;
|
||||
};
|
||||
|
||||
export type EffectModule = { default: new () => EffectComponent };
|
||||
|
||||
export interface EffectOptions {
|
||||
fadeIn?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CSSResultGroup, LitElement, PropertyValues, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import effectBaseStyle from '../../scss/effect-base.scss';
|
||||
import { forceReflow } from '../../utils/basic';
|
||||
|
||||
export abstract class BaseEffectComponent extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public fadeIn = true;
|
||||
|
||||
protected firstUpdated(): void {
|
||||
if (this.fadeIn) {
|
||||
this._startFadeIn();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
// Skip if this is the initial property setting (handled by firstUpdated).
|
||||
if (changedProps.get('fadeIn') !== undefined) {
|
||||
if (!this.fadeIn) {
|
||||
this._setOpacity(1);
|
||||
} else {
|
||||
this._startFadeIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public startFadeOut(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (ev: TransitionEvent) => {
|
||||
if (ev.propertyName === 'opacity') {
|
||||
this.removeEventListener('transitionend', handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.addEventListener('transitionend', handler);
|
||||
this._setOpacity(0);
|
||||
});
|
||||
}
|
||||
|
||||
private _startFadeIn(): void {
|
||||
this._setOpacity(0);
|
||||
forceReflow(this);
|
||||
this._setOpacity(1);
|
||||
}
|
||||
|
||||
private _setOpacity(opacity: number): void {
|
||||
this.style.opacity = `${opacity}`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(effectBaseStyle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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 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();
|
||||
|
||||
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 updated(): void {
|
||||
this._controller.setContainer(this.renderRoot);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return unsafeCSS(effectsStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effects': AdvancedCameraCardEffects;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import fireworkBurstStyle from '../../scss/firework-burst.scss';
|
||||
import './firework-particle';
|
||||
|
||||
const BASE_PARTICLE_COUNT = 36;
|
||||
|
||||
const FIREWORK_COLORS = [
|
||||
'#ff2222', // Bright Red
|
||||
'#ffdd00', // Bright Gold
|
||||
'#22ff22', // Bright Green
|
||||
'#22aaff', // Bright Blue
|
||||
'#ff22ff', // Bright Magenta
|
||||
'#ffffff', // White
|
||||
'#ffaa00', // Bright Orange
|
||||
'#22ffff', // Bright Cyan
|
||||
'#ff66aa', // Pink
|
||||
'#aaaaff', // Lavender
|
||||
];
|
||||
|
||||
type BurstType = 'standard' | 'ring' | 'palm';
|
||||
|
||||
interface ParticleConfig {
|
||||
id: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
color: string;
|
||||
size: string;
|
||||
duration: string;
|
||||
delay: string;
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-firework-burst')
|
||||
export class AdvancedCameraCardFireworkBurst extends LitElement {
|
||||
@property({ type: String })
|
||||
public posX = '50%';
|
||||
|
||||
@property({ type: String })
|
||||
public posY = '50%';
|
||||
|
||||
@property({ type: String })
|
||||
public delay = '0s';
|
||||
|
||||
@property({ type: Number })
|
||||
public scale = 1.0;
|
||||
|
||||
@property({ type: String })
|
||||
public burstType: BurstType = 'standard';
|
||||
|
||||
private _particles: ParticleConfig[] = [];
|
||||
private _color: string = '';
|
||||
private _initialized = false;
|
||||
|
||||
private _initializeParticles(): void {
|
||||
if (this._initialized) {
|
||||
return;
|
||||
}
|
||||
this._initialized = true;
|
||||
|
||||
this._color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
|
||||
|
||||
switch (this.burstType) {
|
||||
case 'ring':
|
||||
this._initializeRingParticles();
|
||||
break;
|
||||
case 'palm':
|
||||
this._initializePalmParticles();
|
||||
break;
|
||||
default:
|
||||
this._initializeStandardParticles();
|
||||
}
|
||||
}
|
||||
|
||||
private _initializeStandardParticles(): void {
|
||||
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale);
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
const angleVariation = (Math.random() - 0.5) * 20;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle + angleVariation,
|
||||
distance: (Math.random() * 80 + 100) * this.scale,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 12 + 18) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.5 + 1.8}s`,
|
||||
delay: `${Math.random() * 0.08}s`,
|
||||
gravity: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _initializeRingParticles(): void {
|
||||
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale * 1.5);
|
||||
const ringDistance = (120 + Math.random() * 40) * this.scale;
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle,
|
||||
distance: ringDistance + (Math.random() - 0.5) * 10,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 8 + 14) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.3 + 1.5}s`,
|
||||
delay: `${Math.random() * 0.02}s`,
|
||||
gravity: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _initializePalmParticles(): void {
|
||||
const particleCount = Math.round(18 * this.scale);
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
const angleVariation = (Math.random() - 0.5) * 15;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle + angleVariation,
|
||||
distance: (Math.random() * 60 + 140) * this.scale,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 10 + 20) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.8 + 2.2}s`,
|
||||
delay: `${Math.random() * 0.05}s`,
|
||||
gravity: 80 + Math.random() * 60,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
this._initializeParticles();
|
||||
return html`
|
||||
${repeat(
|
||||
this._particles,
|
||||
(p) => p.id,
|
||||
(p) => html`
|
||||
<advanced-camera-card-firework-particle
|
||||
.angle=${p.angle}
|
||||
.distance=${p.distance}
|
||||
.color=${p.color}
|
||||
.size=${p.size}
|
||||
.duration=${p.duration}
|
||||
.delay=${p.delay}
|
||||
.gravity=${p.gravity}
|
||||
></advanced-camera-card-firework-particle>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--pos-x', this.posX);
|
||||
this.style.setProperty('--pos-y', this.posY);
|
||||
this.style.setProperty('--delay', this.delay);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(fireworkBurstStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-firework-burst': AdvancedCameraCardFireworkBurst;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import fireworkParticleStyle from '../../scss/firework-particle.scss';
|
||||
|
||||
@customElement('advanced-camera-card-firework-particle')
|
||||
export class AdvancedCameraCardFireworkParticle extends LitElement {
|
||||
@property({ type: Number })
|
||||
public angle = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
public distance = 100;
|
||||
|
||||
@property({ type: String })
|
||||
public color = '#ffcc00';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '4px';
|
||||
|
||||
@property({ type: String })
|
||||
public duration = '1.5s';
|
||||
|
||||
@property({ type: String })
|
||||
public delay = '0s';
|
||||
|
||||
@property({ type: Number })
|
||||
public gravity = 0;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<span class="spark">✦</span>`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const radians = (this.angle * Math.PI) / 180;
|
||||
const endX = Math.cos(radians) * this.distance;
|
||||
const endY = Math.sin(radians) * this.distance + this.gravity;
|
||||
|
||||
this.style.setProperty('--end-x', `${endX}px`);
|
||||
this.style.setProperty('--end-y', `${endY}px`);
|
||||
this.style.setProperty('--color', this.color);
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--duration', this.duration);
|
||||
this.style.setProperty('--delay', this.delay);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(fireworkParticleStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-firework-particle': AdvancedCameraCardFireworkParticle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './firework-burst';
|
||||
|
||||
const INITIAL_BURST_COUNT = 3;
|
||||
const MIN_BURST_COUNT = 2;
|
||||
const MAX_BURST_COUNT = 5;
|
||||
const BURST_CYCLE_SECONDS = 2.0;
|
||||
const MAX_BURST_DELAY_SECONDS = 1.2;
|
||||
|
||||
type BurstType = 'standard' | 'ring' | 'palm';
|
||||
|
||||
interface BurstConfig {
|
||||
id: number;
|
||||
posX: string;
|
||||
posY: string;
|
||||
delay: string;
|
||||
scale: number;
|
||||
burstType: BurstType;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-fireworks')
|
||||
export class AdvancedCameraCardEffectFireworks extends BaseEffectComponent {
|
||||
@state()
|
||||
private _bursts: BurstConfig[] = [];
|
||||
|
||||
private _burstIdCounter = 0;
|
||||
private _timer = new Timer();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._startFireworks();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopFireworks();
|
||||
}
|
||||
|
||||
private _startFireworks(): void {
|
||||
this._createBursts(INITIAL_BURST_COUNT);
|
||||
|
||||
this._timer.startRepeated(BURST_CYCLE_SECONDS, () => {
|
||||
const count =
|
||||
Math.floor(Math.random() * (MAX_BURST_COUNT - MIN_BURST_COUNT + 1)) +
|
||||
MIN_BURST_COUNT;
|
||||
this._createBursts(count);
|
||||
});
|
||||
}
|
||||
|
||||
private _stopFireworks(): void {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
private _createBursts(count: number): void {
|
||||
const newBursts: BurstConfig[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newBursts.push(this._createBurstConfig());
|
||||
}
|
||||
this._bursts = newBursts;
|
||||
}
|
||||
|
||||
private _createBurstConfig(): BurstConfig {
|
||||
// 20% chance of a big burst (scale 1.5-2.0), otherwise normal (scale 0.8-1.2)
|
||||
const isBig = Math.random() < 0.2;
|
||||
const scale = isBig ? Math.random() * 0.5 + 1.5 : Math.random() * 0.4 + 0.8;
|
||||
|
||||
// Burst type distribution: 60% standard, 25% ring, 15% palm
|
||||
const typeRoll = Math.random();
|
||||
let burstType: BurstType;
|
||||
if (typeRoll < 0.6) {
|
||||
burstType = 'standard';
|
||||
} else if (typeRoll < 0.85) {
|
||||
burstType = 'ring';
|
||||
} else {
|
||||
burstType = 'palm';
|
||||
}
|
||||
|
||||
return {
|
||||
id: this._burstIdCounter++,
|
||||
posX: `${Math.random() * 80 + 10}%`,
|
||||
posY: `${Math.random() * 60 + 20}%`,
|
||||
delay: `${Math.random() * MAX_BURST_DELAY_SECONDS}s`,
|
||||
scale,
|
||||
burstType,
|
||||
};
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._bursts,
|
||||
(burst) => burst.id,
|
||||
(burst) => html`
|
||||
<advanced-camera-card-firework-burst
|
||||
.posX=${burst.posX}
|
||||
.posY=${burst.posY}
|
||||
.delay=${burst.delay}
|
||||
.scale=${burst.scale}
|
||||
.burstType=${burst.burstType}
|
||||
></advanced-camera-card-firework-burst>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-fireworks': AdvancedCameraCardEffectFireworks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import ghostStyle from '../../scss/ghost.scss';
|
||||
|
||||
@customElement('advanced-camera-card-effect-ghost')
|
||||
export class AdvancedCameraCardEffectGhost extends BaseEffectComponent {
|
||||
protected render(): TemplateResult {
|
||||
return html`<span class="ghost">👻</span>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(ghostStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-ghost': AdvancedCameraCardEffectGhost;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import heartStyle from '../../scss/heart.scss';
|
||||
|
||||
@customElement('advanced-camera-card-heart')
|
||||
export class AdvancedCameraCardHeart extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '❤️';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public hue = 340;
|
||||
|
||||
@property({ type: Number })
|
||||
public saturation = 80;
|
||||
|
||||
@property({ type: Number })
|
||||
public lightness = 55;
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDuration = '3s';
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public startY = '0%';
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
private _handleAnimationIteration = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'pulse') {
|
||||
this.startX = `${Math.random() * 100}%`;
|
||||
this.startY = `${Math.random() * 100}%`;
|
||||
}
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--hue', `${this.hue}`);
|
||||
this.style.setProperty('--saturation', `${this.saturation}`);
|
||||
this.style.setProperty('--lightness', `${this.lightness}`);
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--pulse-duration', this.pulseDuration);
|
||||
this.style.setProperty('--pulse-delay', this.pulseDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--start-y', this.startY);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(heartStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-heart': AdvancedCameraCardHeart;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './heart';
|
||||
|
||||
const HEART_CHARS = ['❤️', '💖', '💕', '💗', '💓'];
|
||||
const HEART_MAX_COUNT = 50;
|
||||
|
||||
interface HeartConfig {
|
||||
id: number;
|
||||
char: string;
|
||||
|
||||
size: string;
|
||||
|
||||
hue: number;
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
|
||||
maxOpacity: number;
|
||||
|
||||
pulseDuration: string;
|
||||
pulseDelay: string;
|
||||
|
||||
startX: string;
|
||||
startY: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-hearts')
|
||||
export class AdvancedCameraCardEffectHearts extends BaseEffectComponent {
|
||||
private _hearts: HeartConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._hearts = Array.from({ length: HEART_MAX_COUNT }, (_, i) => {
|
||||
const duration = Math.random() * 4 + 4;
|
||||
const delay = -Math.random() * duration * 0.8;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
char: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],
|
||||
size: `${Math.random() * 1.5 + 0.5}em`,
|
||||
hue: Math.random() * 40 + 320,
|
||||
saturation: Math.random() * 40 + 60,
|
||||
lightness: Math.random() * 20 + 45,
|
||||
maxOpacity: Math.random() * 0.5 + 0.2,
|
||||
pulseDuration: `${duration}s`,
|
||||
pulseDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 100}%`,
|
||||
startY: `${Math.random() * 100}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._hearts,
|
||||
(heart) => heart.id,
|
||||
(heart) => html`
|
||||
<advanced-camera-card-heart
|
||||
.char=${heart.char}
|
||||
.size=${heart.size}
|
||||
.hue=${heart.hue}
|
||||
.saturation=${heart.saturation}
|
||||
.lightness=${heart.lightness}
|
||||
.maxOpacity=${heart.maxOpacity}
|
||||
.pulseDuration=${heart.pulseDuration}
|
||||
.pulseDelay=${heart.pulseDelay}
|
||||
.startX=${heart.startX}
|
||||
.startY=${heart.startY}
|
||||
></advanced-camera-card-heart>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-hearts': AdvancedCameraCardEffectHearts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import shamrockStyle from '../../scss/shamrock.scss';
|
||||
|
||||
@customElement('advanced-camera-card-shamrock')
|
||||
export class AdvancedCameraCardShamrock extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '☘️';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDuration = '3s';
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public startY = '0%';
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
private _handleAnimationIteration = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'shamrock-pulse') {
|
||||
this.startX = `${Math.random() * 100}%`;
|
||||
this.startY = `${Math.random() * 100}%`;
|
||||
}
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--pulse-duration', this.pulseDuration);
|
||||
this.style.setProperty('--pulse-delay', this.pulseDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--start-y', this.startY);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(shamrockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-shamrock': AdvancedCameraCardShamrock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './shamrock';
|
||||
|
||||
const SHAMROCK_COUNT = 10;
|
||||
|
||||
interface ShamrockConfig {
|
||||
id: number;
|
||||
|
||||
size: string;
|
||||
|
||||
maxOpacity: number;
|
||||
|
||||
pulseDuration: string;
|
||||
pulseDelay: string;
|
||||
|
||||
startX: string;
|
||||
startY: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-shamrocks')
|
||||
export class AdvancedCameraCardEffectShamrocks extends BaseEffectComponent {
|
||||
private _shamrocks: ShamrockConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._shamrocks = Array.from({ length: SHAMROCK_COUNT }, (_, i) => {
|
||||
const duration = Math.random() * 3 + 5;
|
||||
const delay = -Math.random() * duration * 0.9;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
size: `${Math.random() * 12 + 8}em`,
|
||||
maxOpacity: Math.random() * 0.3 + 0.5,
|
||||
pulseDuration: `${duration}s`,
|
||||
pulseDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 80 + 10}%`,
|
||||
startY: `${Math.random() * 80 + 10}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._shamrocks,
|
||||
(shamrock) => shamrock.id,
|
||||
(shamrock) => html`
|
||||
<advanced-camera-card-shamrock
|
||||
.size=${shamrock.size}
|
||||
.maxOpacity=${shamrock.maxOpacity}
|
||||
.pulseDuration=${shamrock.pulseDuration}
|
||||
.pulseDelay=${shamrock.pulseDelay}
|
||||
.startX=${shamrock.startX}
|
||||
.startY=${shamrock.startY}
|
||||
></advanced-camera-card-shamrock>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-shamrocks': AdvancedCameraCardEffectShamrocks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './snowflake';
|
||||
|
||||
const SNOWFLAKE_CHARS = ['❄', '❅', '❆'];
|
||||
const MAX_SNOWFLAKES = 50;
|
||||
|
||||
interface SnowflakeConfig {
|
||||
id: number;
|
||||
char: string;
|
||||
size: string;
|
||||
maxOpacity: number;
|
||||
fallDuration: string;
|
||||
fallDelay: string;
|
||||
startX: string;
|
||||
endX: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-snow')
|
||||
export class AdvancedCameraCardEffectSnow extends BaseEffectComponent {
|
||||
private _snowflakes: SnowflakeConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._snowflakes = Array.from({ length: MAX_SNOWFLAKES }, (_, i) => {
|
||||
const duration = Math.random() * 10 + 10;
|
||||
const delay = -Math.random() * duration * 0.8;
|
||||
return {
|
||||
id: i,
|
||||
char: SNOWFLAKE_CHARS[Math.floor(Math.random() * SNOWFLAKE_CHARS.length)],
|
||||
size: `${Math.random() * 1.5 + 0.5}em`,
|
||||
maxOpacity: Math.random() * 0.5 + 0.5,
|
||||
fallDuration: `${duration}s`,
|
||||
fallDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 100}%`,
|
||||
endX: `${Math.random() * 100}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._snowflakes,
|
||||
(snowflake) => snowflake.id,
|
||||
(snowflake) => html`
|
||||
<advanced-camera-card-snowflake
|
||||
.char=${snowflake.char}
|
||||
.size=${snowflake.size}
|
||||
.maxOpacity=${snowflake.maxOpacity}
|
||||
.fallDuration=${snowflake.fallDuration}
|
||||
.fallDelay=${snowflake.fallDelay}
|
||||
.startX=${snowflake.startX}
|
||||
.endX=${snowflake.endX}
|
||||
></advanced-camera-card-snowflake>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-snow': AdvancedCameraCardEffectSnow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import snowflakeStyle from '../../scss/snowflake.scss';
|
||||
|
||||
@customElement('advanced-camera-card-snowflake')
|
||||
export class AdvancedCameraCardSnowflake extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '❄';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public fallDuration = '10s';
|
||||
|
||||
@property({ type: String })
|
||||
public fallDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public endX = '0%';
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--fall-duration', this.fallDuration);
|
||||
this.style.setProperty('--fall-delay', this.fallDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--end-x', this.endX);
|
||||
this.style.fontSize = this.size;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(snowflakeStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-snowflake': AdvancedCameraCardSnowflake;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,76 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import loadingStyle from '../scss/loading.scss';
|
||||
import { EffectName, EffectsControllerAPI } from '../types';
|
||||
import { getReleaseVersion } from '../utils/diagnostics';
|
||||
import './icon';
|
||||
|
||||
// Map of "MM-DD" to effect name for special dates.
|
||||
const DATE_EFFECTS: Record<string, EffectName> = {
|
||||
// New Year's Day
|
||||
'01-01': 'fireworks',
|
||||
|
||||
// Valentine's Day
|
||||
'02-14': 'hearts',
|
||||
|
||||
// St. Patrick's Day
|
||||
'03-17': 'shamrocks',
|
||||
|
||||
// Halloween
|
||||
'10-31': 'ghost',
|
||||
|
||||
// Christmas
|
||||
'12-25': 'snow',
|
||||
};
|
||||
|
||||
const getDateEffect = (): EffectName | null => {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return DATE_EFFECTS[`${month}-${day}`] ?? null;
|
||||
};
|
||||
|
||||
@customElement('advanced-camera-card-loading')
|
||||
export class AdvancedCameraCardLoading extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public effectsControllerAPI?: EffectsControllerAPI | null;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public loaded = false;
|
||||
|
||||
private _effectName: EffectName | null = null;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopEffect();
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const effect = getDateEffect();
|
||||
if (!effect) {
|
||||
this._stopEffect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.loaded) {
|
||||
this._startEffect(effect);
|
||||
} else {
|
||||
this._stopEffect();
|
||||
}
|
||||
}
|
||||
|
||||
private _startEffect(effect: EffectName): void {
|
||||
this.effectsControllerAPI?.startEffect(effect, { fadeIn: false });
|
||||
this._effectName = effect;
|
||||
}
|
||||
|
||||
private _stopEffect(): void {
|
||||
if (this._effectName) {
|
||||
this.effectsControllerAPI?.stopEffect(this._effectName);
|
||||
}
|
||||
this._effectName = null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'iris' }}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
CONF_MENU_BUTTONS_TIMELINE,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS,
|
||||
CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE,
|
||||
@@ -120,6 +121,7 @@ export const LOW_PERFORMANCE_PROFILE = {
|
||||
// Disable all optional performance related features.
|
||||
[CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR]: false,
|
||||
[CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR]: false,
|
||||
[CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS]: false,
|
||||
|
||||
// Load fewer media items by default.
|
||||
[CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE]: 10,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { EffectName } from '../../../../types';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './base';
|
||||
|
||||
const effectNameSchema = z.enum([
|
||||
'fireworks',
|
||||
'ghost',
|
||||
'hearts',
|
||||
'shamrocks',
|
||||
'snow',
|
||||
]) satisfies z.ZodType<EffectName>;
|
||||
|
||||
const effectActionSchema = z.enum(['start', 'stop', 'toggle']);
|
||||
export type EffectAction = z.infer<typeof effectActionSchema>;
|
||||
|
||||
export const effectActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend(
|
||||
{
|
||||
advanced_camera_card_action: z.literal('effect'),
|
||||
effect: effectNameSchema,
|
||||
effect_action: effectActionSchema.default('toggle'),
|
||||
},
|
||||
);
|
||||
|
||||
export type EffectActionConfig = z.infer<typeof effectActionConfigSchema>;
|
||||
@@ -3,6 +3,7 @@ import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
||||
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
||||
import { effectActionConfigSchema } from './custom/effect';
|
||||
import { generalActionConfigSchema } from './custom/general';
|
||||
import { internalCallbackActionConfigSchema } from './custom/internal';
|
||||
import { logActionConfigSchema } from './custom/log';
|
||||
@@ -41,6 +42,7 @@ export const statusBarActionConfigSchema: z.ZodSchema<
|
||||
|
||||
const advancedCameraCardCustomActionSchema = z.union([
|
||||
cameraSelectActionConfigSchema,
|
||||
effectActionConfigSchema,
|
||||
generalActionConfigSchema,
|
||||
internalCallbackActionConfigSchema,
|
||||
logActionConfigSchema,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../../const';
|
||||
export const performanceConfigDefault = {
|
||||
features: {
|
||||
animated_progress_indicator: true,
|
||||
card_loading_effects: true,
|
||||
card_loading_indicator: true,
|
||||
media_chunk_size: MEDIA_CHUNK_SIZE_DEFAULT,
|
||||
},
|
||||
@@ -20,6 +21,9 @@ export const performanceConfigSchema = z
|
||||
animated_progress_indicator: z
|
||||
.boolean()
|
||||
.default(performanceConfigDefault.features.animated_progress_indicator),
|
||||
card_loading_effects: z
|
||||
.boolean()
|
||||
.default(performanceConfigDefault.features.card_loading_effects),
|
||||
card_loading_indicator: z
|
||||
.boolean()
|
||||
.default(performanceConfigDefault.features.card_loading_indicator),
|
||||
|
||||
@@ -389,6 +389,7 @@ export const CONF_OVERRIDES = 'overrides' as const;
|
||||
|
||||
const CONF_PERFORMANCE = 'performance' as const;
|
||||
export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`;
|
||||
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS = `${CONF_PERFORMANCE}.features.card_loading_effects`;
|
||||
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR = `${CONF_PERFORMANCE}.features.card_loading_indicator`;
|
||||
export const CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`;
|
||||
export const CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS = `${CONF_PERFORMANCE}.features.max_simultaneous_engine_requests`;
|
||||
|
||||
@@ -191,6 +191,7 @@ import {
|
||||
CONF_MENU_POSITION,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS,
|
||||
CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE,
|
||||
@@ -3214,6 +3215,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR,
|
||||
this._defaults.performance.features.card_loading_indicator,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS,
|
||||
this._defaults.performance.features.card_loading_effects,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
this._defaults.performance.features.animated_progress_indicator,
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicador animat del progrés",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Opcions de característiques",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Animated Progress Indicator",
|
||||
"card_loading_effects": "Card Loading Effects",
|
||||
"card_loading_indicator": "Card Loading Indicator",
|
||||
"editor_label": "Feature Options",
|
||||
"max_simultaneous_engine_requests": "Max simultaneous camera engine requests",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicateur de progression animé",
|
||||
"card_loading_effects": "Effets au chargement",
|
||||
"card_loading_indicator": "Indicateur de chargement de la carte",
|
||||
"editor_label": "Options de fonctionnalités",
|
||||
"max_simultaneous_engine_requests": "Nombre maximal de requêtes simultanées au moteur de caméra",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicatore di avanzamento animato",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Opzioni funzionalità",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicador de Carregamento Animado",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Opções de recursos",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Animação na barra de progresso",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Editor de etiquetas",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 1.5s ease-in;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@import './z-index.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
|
||||
pointer-events: none;
|
||||
z-index: #{$z-index-effect};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
left: var(--pos-x, 50%);
|
||||
top: var(--pos-y, 50%);
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0;
|
||||
animation: burst-appear 0.01s linear forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes burst-appear {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform, opacity;
|
||||
animation: explode var(--duration) ease-out forwards;
|
||||
animation-delay: var(--delay);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spark {
|
||||
font-size: var(--size, 4px);
|
||||
color: var(--color, #ffcc00);
|
||||
text-shadow:
|
||||
0 0 4px #fff,
|
||||
0 0 8px var(--color),
|
||||
0 0 16px var(--color),
|
||||
0 0 32px var(--color),
|
||||
0 0 48px var(--color);
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
|
||||
@keyframes explode {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(var(--end-x), var(--end-y)) scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 1.5s ease-in;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
font-size: 10em;
|
||||
color: #ffffff;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0.5;
|
||||
filter: drop-shadow(0 0 20px #ffffff) drop-shadow(0 0 40px #e0e0e0)
|
||||
drop-shadow(0 0 60px #c0c0c0);
|
||||
animation: ghost-float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ghost-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
filter: drop-shadow(0 0 20px #ffffff) drop-shadow(0 0 40px #e0e0e0)
|
||||
drop-shadow(0 0 60px #c0c0c0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
filter: drop-shadow(0 0 30px #ffffff) drop-shadow(0 0 60px #e0e0e0)
|
||||
drop-shadow(0 0 90px #c0c0c0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
top: var(--start-y);
|
||||
|
||||
font-size: var(--size, 1em);
|
||||
color: hsl(var(--hue, 340), var(--saturation, 80%), var(--lightness, 55%));
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: pulse var(--pulse-duration) ease-in-out infinite;
|
||||
animation-delay: var(--pulse-delay);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
85% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.heart {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
top: var(--start-y);
|
||||
|
||||
font-size: var(--size, 1em);
|
||||
color: hsl(var(--hue, 340), var(--saturation, 80%), var(--lightness, 55%));
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: pulse var(--pulse-duration) ease-in-out infinite;
|
||||
animation-delay: var(--pulse-delay);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
85% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
top: var(--start-y);
|
||||
|
||||
font-size: var(--size, 1em);
|
||||
|
||||
// Center the shamrock on its position.
|
||||
transform-origin: center center;
|
||||
translate: -50% -50%;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform, opacity;
|
||||
animation: shamrock-pulse var(--pulse-duration) ease-in-out infinite;
|
||||
animation-delay: var(--pulse-delay);
|
||||
}
|
||||
|
||||
@keyframes shamrock-pulse {
|
||||
0% {
|
||||
scale: 0;
|
||||
rotate: -20deg;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
50% {
|
||||
scale: 1;
|
||||
rotate: 10deg;
|
||||
}
|
||||
80% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
scale: 0;
|
||||
rotate: 25deg;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.snowflake {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
|
||||
color: white;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: fall var(--fall-duration) linear infinite;
|
||||
animation-delay: var(--fall-delay);
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
// Start above the card.
|
||||
top: -2em;
|
||||
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
25% {
|
||||
top: 25%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 8px));
|
||||
}
|
||||
50% {
|
||||
top: 50%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) - 10px));
|
||||
}
|
||||
75% {
|
||||
top: 75%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 20px));
|
||||
}
|
||||
90% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
// 100% ensures snowflakes fall through the entire card height
|
||||
top: 100%;
|
||||
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x)));
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
|
||||
color: white;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: fall var(--fall-duration) linear infinite;
|
||||
animation-delay: var(--fall-delay);
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
// Start above the card.
|
||||
top: -2em;
|
||||
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
25% {
|
||||
top: 25%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 8px));
|
||||
}
|
||||
50% {
|
||||
top: 50%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) - 10px));
|
||||
}
|
||||
75% {
|
||||
top: 75%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 20px));
|
||||
}
|
||||
90% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
// 100% ensures snowflakes fall through the entire card height
|
||||
top: 100%;
|
||||
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x)));
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
// More-info dialog box has a z-index of 8, so everything meaningful needs to be
|
||||
// below that.
|
||||
|
||||
$z-index-effect: 7;
|
||||
$z-index-loading: 6;
|
||||
$z-index-submenu: 5;
|
||||
|
||||
|
||||
+10
-1
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
|
||||
import type { EffectOptions } from './components-lib/effects/types';
|
||||
import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
|
||||
|
||||
export type ClipsOrSnapshots = 'clips' | 'snapshots';
|
||||
export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all';
|
||||
@@ -177,3 +178,11 @@ export const signedPathSchema = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
export type SignedPath = z.infer<typeof signedPathSchema>;
|
||||
|
||||
export type EffectName = 'fireworks' | 'ghost' | 'hearts' | 'shamrocks' | 'snow';
|
||||
|
||||
export interface EffectsControllerAPI {
|
||||
startEffect(name: EffectName, options?: EffectOptions): Promise<void>;
|
||||
stopEffect(effect: EffectName): void;
|
||||
toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { CardActionsAPI } from '../card-controller/types.js';
|
||||
import { ZoomSettingsBase } from '../components-lib/zoom/types.js';
|
||||
import { CameraSelectActionConfig } from '../config/schema/actions/custom/camera-select.js';
|
||||
import { DisplayModeActionConfig } from '../config/schema/actions/custom/display-mode.js';
|
||||
import {
|
||||
EffectAction,
|
||||
EffectActionConfig,
|
||||
} from '../config/schema/actions/custom/effect.js';
|
||||
import {
|
||||
AdvancedCameraCardGeneralAction,
|
||||
GeneralActionConfig,
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
} from '../config/schema/actions/types.js';
|
||||
import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js';
|
||||
import { ServiceCallRequest } from '../ha/types.js';
|
||||
import { EffectName } from '../types.js';
|
||||
import { arrayify } from './basic.js';
|
||||
|
||||
export function createGeneralAction(
|
||||
@@ -239,6 +244,22 @@ export function createSelectOptionAction(
|
||||
});
|
||||
}
|
||||
|
||||
export function createEffectAction(
|
||||
effectName: EffectName,
|
||||
effectAction: EffectAction,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
},
|
||||
): EffectActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'effect',
|
||||
effect: effectName,
|
||||
effect_action: effectAction,
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
|
||||
@@ -327,3 +327,8 @@ export const generateFloatApproximatelyEqualsCustomizer = (
|
||||
export const convertHTTPAdressToWebsocket = (url: string): string => {
|
||||
return url.replace(/^http/i, 'ws');
|
||||
};
|
||||
|
||||
export const forceReflow = (element: HTMLElement): void => {
|
||||
// Force reflow by measuring the height.
|
||||
void element.offsetHeight;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EffectAction } from '../../../../src/card-controller/actions/actions/effect';
|
||||
import { createEffectAction } from '../../../../src/utils/action';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
|
||||
describe('EffectAction', () => {
|
||||
it('should call startEffect when action is start', async () => {
|
||||
const api = createCardAPI();
|
||||
const actionConfig = createEffectAction('snow', 'start');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getEffectsControllerAPI()?.startEffect).toHaveBeenCalledWith('snow');
|
||||
});
|
||||
|
||||
it('should call stopEffect when action is stop', async () => {
|
||||
const api = createCardAPI();
|
||||
const actionConfig = createEffectAction('snow', 'stop');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getEffectsControllerAPI()?.stopEffect).toHaveBeenCalledWith('snow');
|
||||
});
|
||||
|
||||
it('should call toggleEffect when action is toggle', async () => {
|
||||
const api = createCardAPI();
|
||||
const actionConfig = createEffectAction('snow', 'toggle');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getEffectsControllerAPI()?.toggleEffect).toHaveBeenCalledWith('snow');
|
||||
});
|
||||
|
||||
it('should not throw when effectsControllerAPI is null', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getEffectsControllerAPI).mockReturnValue(null);
|
||||
const actionConfig = createEffectAction('snow', 'start');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await expect(action.execute(api)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have a no-op stop method', async () => {
|
||||
const actionConfig = createEffectAction('snow', 'stop');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await expect(action.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import { ViewAction } from '../../../src/card-controller/actions/actions/view';
|
||||
import { ActionFactory } from '../../../src/card-controller/actions/factory';
|
||||
import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/schema/actions/custom/internal';
|
||||
import { ActionConfig } from '../../../src/config/schema/actions/types';
|
||||
import { EffectAction } from '../../../src/card-controller/actions/actions/effect';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ActionFactory', () => {
|
||||
@@ -188,6 +189,7 @@ describe('ActionFactory', () => {
|
||||
InternalCallbackAction,
|
||||
],
|
||||
[{ advanced_camera_card_action: 'reload' as const }, ReloadAction],
|
||||
[{ advanced_camera_card_action: 'effect' as const }, EffectAction],
|
||||
])(
|
||||
'advanced_camera_card_action: $advanced_camera_card_action',
|
||||
(action: Partial<ActionConfig>, classObject: object) => {
|
||||
|
||||
@@ -152,6 +152,7 @@ describe('ConfigManager', () => {
|
||||
features: {
|
||||
animated_progress_indicator: true,
|
||||
card_loading_indicator: true,
|
||||
card_loading_effects: true,
|
||||
media_chunk_size: 50,
|
||||
},
|
||||
style: {
|
||||
|
||||
@@ -32,6 +32,8 @@ import { AdvancedCameraCardEditor } from '../../src/editor';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media';
|
||||
import { EffectsControllerAPI } from '../../src/types';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
vi.mock('../../src/camera-manager/manager');
|
||||
vi.mock('../../src/card-controller/actions/actions-manager');
|
||||
@@ -70,7 +72,7 @@ const createCardElement = (): CardHTMLElement => {
|
||||
};
|
||||
|
||||
const createController = (): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn());
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn(), vi.fn());
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -83,15 +85,23 @@ describe('CardController', () => {
|
||||
const element = createCardElement();
|
||||
const scrollCallback = vi.fn();
|
||||
const menuToggleCallback = vi.fn();
|
||||
const effectsControllerAPI = mock<EffectsControllerAPI>();
|
||||
const effectsControllerAPICallback = vi.fn().mockReturnValue(effectsControllerAPI);
|
||||
|
||||
const manager = new CardController(element, scrollCallback, menuToggleCallback);
|
||||
const controller = new CardController(
|
||||
element,
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
effectsControllerAPICallback,
|
||||
);
|
||||
|
||||
expect(CardElementManager).toBeCalledWith(
|
||||
manager,
|
||||
controller,
|
||||
element,
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
);
|
||||
expect(controller.getEffectsControllerAPI()).toBe(effectsControllerAPI);
|
||||
});
|
||||
|
||||
describe('accessors', () => {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EffectsController } from '../../../src/components-lib/effects/effects-controller';
|
||||
import { EffectComponent } from '../../../src/components-lib/effects/types';
|
||||
import { EffectName } from '../../../src/types';
|
||||
|
||||
vi.mock('../../../src/components/effects/fireworks', () => ({
|
||||
AdvancedCameraCardEffectFireworks: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/ghost', () => ({
|
||||
AdvancedCameraCardEffectGhost: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/hearts', () => ({
|
||||
AdvancedCameraCardEffectHearts: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/shamrocks', () => ({
|
||||
AdvancedCameraCardEffectShamrocks: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/snow', () => ({
|
||||
AdvancedCameraCardEffectSnow: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
const createMockEffectComponent = (): EffectComponent => {
|
||||
const element = document.createElement('div') as unknown as EffectComponent;
|
||||
element.fadeIn = true;
|
||||
element.startFadeOut = vi.fn().mockResolvedValue(undefined);
|
||||
return element;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('EffectsController', () => {
|
||||
let container: HTMLElement;
|
||||
let controller: EffectsController;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
controller = new EffectsController();
|
||||
controller.setContainer(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('startEffect', () => {
|
||||
it('should load and start snow effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start hearts effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('hearts');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start shamrocks effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('shamrocks');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start fireworks effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('fireworks');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start ghost effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('ghost');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should set fadeIn to true by default', async () => {
|
||||
await controller.startEffect('snow');
|
||||
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
expect(effectElement.fadeIn).toBe(true);
|
||||
});
|
||||
|
||||
it('should set fadeIn from options', async () => {
|
||||
await controller.startEffect('snow', { fadeIn: false });
|
||||
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
expect(effectElement.fadeIn).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start effect if container is null', async () => {
|
||||
controller.setContainer(null);
|
||||
|
||||
await controller.startEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not start effect if already active', async () => {
|
||||
await controller.startEffect('snow');
|
||||
await controller.startEffect('snow');
|
||||
|
||||
// Should only have one child since the second call is ignored.
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow starting different effects simultaneously', async () => {
|
||||
await controller.startEffect('snow');
|
||||
await controller.startEffect('hearts');
|
||||
|
||||
expect(container.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return early if effect module cannot be loaded', async () => {
|
||||
// Use an effect name that doesn't exist in the registry
|
||||
await controller.startEffect('unknown' as EffectName);
|
||||
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopEffect', () => {
|
||||
it('should stop an active effect', async () => {
|
||||
await controller.startEffect('snow');
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
|
||||
await controller.stopEffect('snow');
|
||||
|
||||
expect(effectElement.startFadeOut).toHaveBeenCalled();
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should do nothing if effect is not active', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.stopEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow starting effect again after stopping', async () => {
|
||||
await controller.startEffect('snow');
|
||||
await controller.stopEffect('snow');
|
||||
await controller.startEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should cancel effect when stopped during loading', async () => {
|
||||
// Start the effect but don't await it - simulates the loading phase.
|
||||
const startPromise = controller.startEffect('snow');
|
||||
|
||||
// Stop the effect immediately while it's still loading.
|
||||
await controller.stopEffect('snow');
|
||||
|
||||
// Wait for start to complete.
|
||||
await startPromise;
|
||||
|
||||
// The effect should not appear since it was stopped during loading.
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleEffect', () => {
|
||||
it('should start effect when not active', async () => {
|
||||
await controller.toggleEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should stop effect when active', async () => {
|
||||
await controller.startEffect('snow');
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
|
||||
await controller.toggleEffect('snow');
|
||||
|
||||
expect(effectElement.startFadeOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass options when starting effect', async () => {
|
||||
await controller.toggleEffect('snow', { fadeIn: false });
|
||||
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
expect(effectElement.fadeIn).toBe(false);
|
||||
});
|
||||
|
||||
it('should cancel effect when toggled during loading', async () => {
|
||||
// Start the effect but don't await it - simulates the loading phase.
|
||||
const startPromise = controller.startEffect('snow');
|
||||
|
||||
// Toggle the effect immediately while it's still loading.
|
||||
await controller.toggleEffect('snow');
|
||||
|
||||
// Wait for start to complete.
|
||||
await startPromise;
|
||||
|
||||
// The effect should not appear since it was toggled (stopped) during loading.
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ it('should contain expected defaults', () => {
|
||||
'menu.style': 'outside',
|
||||
'performance.features.animated_progress_indicator': false,
|
||||
'performance.features.card_loading_indicator': false,
|
||||
'performance.features.card_loading_effects': false,
|
||||
'performance.features.max_simultaneous_engine_requests': 1,
|
||||
'performance.features.media_chunk_size': 10,
|
||||
'performance.style.border_radius': false,
|
||||
|
||||
@@ -287,6 +287,7 @@ describe('config defaults', () => {
|
||||
performance: {
|
||||
features: {
|
||||
animated_progress_indicator: true,
|
||||
card_loading_effects: true,
|
||||
card_loading_indicator: true,
|
||||
media_chunk_size: 50,
|
||||
},
|
||||
|
||||
+7
-1
@@ -57,7 +57,12 @@ import {
|
||||
import { Device } from '../src/ha/registry/device/types';
|
||||
import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
|
||||
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
|
||||
import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
EffectsControllerAPI,
|
||||
Interaction,
|
||||
MediaLoadedInfo,
|
||||
} from '../src/types';
|
||||
import { EventViewMedia, ViewMedia, ViewMediaType } from '../src/view/item';
|
||||
import { QueryResults } from '../src/view/query-results';
|
||||
import { ViewItemCapabilities } from '../src/view/types';
|
||||
@@ -580,6 +585,7 @@ export const createCardAPI = (): CardController => {
|
||||
api.getCardElementManager.mockReturnValue(mock<CardElementManager>());
|
||||
api.getConditionStateManager.mockReturnValue(mock<ConditionStateManager>());
|
||||
api.getConfigManager.mockReturnValue(mock<ConfigManager>());
|
||||
api.getEffectsControllerAPI.mockReturnValue(mock<EffectsControllerAPI>());
|
||||
api.getEntityRegistryManager.mockReturnValue(mock<EntityRegistryManager>());
|
||||
api.getExpandManager.mockReturnValue(mock<ExpandManager>());
|
||||
api.getFoldersManager.mockReturnValue(mock<FoldersManager>());
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActionConfig } from '../../src/config/schema/actions/types.js';
|
||||
import {
|
||||
createCameraAction,
|
||||
createDisplayModeAction,
|
||||
createEffectAction,
|
||||
createGeneralAction,
|
||||
createInternalCallbackAction,
|
||||
createLogAction,
|
||||
@@ -300,6 +301,22 @@ describe('createSelectOptionAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEffectAction', () => {
|
||||
it('should create effect action', () => {
|
||||
expect(
|
||||
createEffectAction('snow', 'start', {
|
||||
cardID: 'card_id',
|
||||
}),
|
||||
).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'effect',
|
||||
effect: 'snow',
|
||||
effect_action: 'start',
|
||||
card_id: 'card_id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionConfigGivenAction', () => {
|
||||
const action = createViewAction('clips');
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
dayToDate,
|
||||
desparsifyArrays,
|
||||
errorToConsole,
|
||||
forceReflow,
|
||||
formatDate,
|
||||
formatDateAndTime,
|
||||
generateFloatApproximatelyEqualsCustomizer,
|
||||
@@ -535,3 +536,16 @@ describe('convertHTTPAdressToWebsocket', () => {
|
||||
expect(convertHTTPAdressToWebsocket('HtTp://example.com')).toBe('ws://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceReflow', () => {
|
||||
it('should access offsetHeight to trigger reflow', () => {
|
||||
const element = document.createElement('div');
|
||||
const offsetHeightSpy = vi
|
||||
.spyOn(element, 'offsetHeight', 'get')
|
||||
.mockReturnValue(100);
|
||||
|
||||
forceReflow(element);
|
||||
|
||||
expect(offsetHeightSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user