From 3279481b0eac849de976a0b0b471882868439275 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 6 Dec 2025 18:21:44 -0800 Subject: [PATCH] feat: Add festive visual effects (#2252) > [!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. > > 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). --- docs/configuration/actions/custom/README.md | 25 ++ docs/configuration/performance.md | 4 +- src/card-controller/actions/actions/effect.ts | 21 ++ src/card-controller/actions/factory.ts | 3 + src/card-controller/controller.ts | 11 + src/card-controller/types.ts | 2 + src/card.ts | 207 +++++++++-------- .../effects/effects-controller.ts | 98 ++++++++ src/components-lib/effects/types.ts | 10 + src/components/effects/base.ts | 53 +++++ src/components/effects/effects.ts | 40 ++++ src/components/effects/firework-burst.ts | 172 ++++++++++++++ src/components/effects/firework-particle.ts | 54 +++++ src/components/effects/fireworks.ts | 115 ++++++++++ src/components/effects/ghost.ts | 21 ++ src/components/effects/heart.ts | 79 +++++++ src/components/effects/hearts.ts | 83 +++++++ src/components/effects/shamrock.ts | 67 ++++++ src/components/effects/shamrocks.ts | 69 ++++++ src/components/effects/snow.ts | 68 ++++++ src/components/effects/snowflake.ts | 50 ++++ src/components/loading.ts | 67 +++++- src/config/profiles/low-performance.ts | 2 + src/config/schema/actions/custom/effect.ts | 24 ++ src/config/schema/actions/types.ts | 2 + src/config/schema/performance.ts | 4 + src/const.ts | 1 + src/editor.ts | 5 + src/localize/languages/ca.json | 1 + src/localize/languages/en.json | 1 + src/localize/languages/fr.json | 1 + src/localize/languages/it.json | 1 + src/localize/languages/pt-BR.json | 1 + src/localize/languages/pt-PT.json | 1 + src/scss/effect-base.scss | 6 + src/scss/effects.scss | 12 + src/scss/firework-burst.scss | 18 ++ src/scss/firework-particle.scss | 40 ++++ src/scss/ghost.scss | 35 +++ src/scss/heart.scss | 36 +++ src/scss/hearts.scss | 36 +++ src/scss/shamrock.scss | 42 ++++ src/scss/snow.scss | 48 ++++ src/scss/snowflake.scss | 48 ++++ src/scss/z-index.scss | 1 + src/types.ts | 11 +- src/utils/action.ts | 21 ++ src/utils/basic.ts | 5 + .../actions/actions/effect.test.ts | 52 +++++ tests/card-controller/actions/factory.test.ts | 2 + .../config/config-manager.test.ts | 1 + tests/card-controller/controller.test.ts | 16 +- .../effects/effects-controller.test.ts | 213 ++++++++++++++++++ tests/config/profiles/low-performance.test.ts | 1 + tests/config/types.test.ts | 1 + tests/test-utils.ts | 8 +- tests/utils/action.test.ts | 17 ++ tests/utils/basic.test.ts | 14 ++ 58 files changed, 1943 insertions(+), 104 deletions(-) create mode 100644 src/card-controller/actions/actions/effect.ts create mode 100644 src/components-lib/effects/effects-controller.ts create mode 100644 src/components-lib/effects/types.ts create mode 100644 src/components/effects/base.ts create mode 100644 src/components/effects/effects.ts create mode 100644 src/components/effects/firework-burst.ts create mode 100644 src/components/effects/firework-particle.ts create mode 100644 src/components/effects/fireworks.ts create mode 100644 src/components/effects/ghost.ts create mode 100644 src/components/effects/heart.ts create mode 100644 src/components/effects/hearts.ts create mode 100644 src/components/effects/shamrock.ts create mode 100644 src/components/effects/shamrocks.ts create mode 100644 src/components/effects/snow.ts create mode 100644 src/components/effects/snowflake.ts create mode 100644 src/config/schema/actions/custom/effect.ts create mode 100644 src/scss/effect-base.scss create mode 100644 src/scss/effects.scss create mode 100644 src/scss/firework-burst.scss create mode 100644 src/scss/firework-particle.scss create mode 100644 src/scss/ghost.scss create mode 100644 src/scss/heart.scss create mode 100644 src/scss/hearts.scss create mode 100644 src/scss/shamrock.scss create mode 100644 src/scss/snow.scss create mode 100644 src/scss/snowflake.scss create mode 100644 tests/card-controller/actions/actions/effect.test.ts create mode 100644 tests/components-lib/effects/effects-controller.test.ts diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index be269947..1c7aaca1 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -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 ``` diff --git a/docs/configuration/performance.md b/docs/configuration/performance.md index b29728ea..1f26a77b 100644 --- a/docs/configuration/performance.md +++ b/docs/configuration/performance.md @@ -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 diff --git a/src/card-controller/actions/actions/effect.ts b/src/card-controller/actions/actions/effect.ts new file mode 100644 index 00000000..3d5c4630 --- /dev/null +++ b/src/card-controller/actions/actions/effect.ts @@ -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 { + public async execute(api: CardActionsAPI): Promise { + 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; + } + } +} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index 7194ba57..ea0b105f 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -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': diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 486f4091..d522edb2 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -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; } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 3728f061..05a34e26 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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; diff --git a/src/card.ts b/src/card.ts index 4c111cbe..dbdc1741 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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 = createRef(); + protected _refElements: Ref = createRef(); + protected _refMain: Ref = createRef(); protected _refMenu: Ref = createRef(); protected _refOverlay: Ref = createRef(); - protected _refMain: Ref = createRef(); - protected _refElements: Ref = createRef(); protected _refViews: Ref = 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` ) => - this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} - @advanced-camera-card:media:loaded=${(ev: CustomEvent) => - 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`` - : ''} - ${this._renderMenuStatusContainer('top')} - ${this._renderMenuStatusContainer('overlay')} -
- - ${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` + ) => + this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} + @advanced-camera-card:media:loaded=${(ev: CustomEvent) => + 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`` : ''} -
- ${this._renderMenuStatusContainer('bottom')} - ${this._config?.elements - ? // Elements need to render after the main views so it can render 'on - // top'. - html` + ) => { - this._menuButtonController.addDynamicMenuButton(ev.detail); - this.requestUpdate(); - }} - @advanced-camera-card:menu:remove=${(ev: CustomEvent) => { - this._menuButtonController.removeDynamicMenuButton(ev.detail); - this.requestUpdate(); - }} - @advanced-camera-card:status-bar:add=${( - ev: CustomEvent, - ) => { - this._controller - .getStatusBarItemManager() - .addDynamicStatusBarItem(ev.detail); - }} - @advanced-camera-card:status-bar:remove=${( - ev: CustomEvent, - ) => { - this._controller - .getStatusBarItemManager() - .removeDynamicStatusBarItem(ev.detail); - }} - @advanced-camera-card:condition-state-manager:get=${( - ev: ConditionStateManagerGetEvent, - ) => { - ev.conditionStateManager = this._controller.getConditionStateManager(); - }} - > - ` - : ``} -
`, + .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status + ? this._controller.getTriggersManager().getTriggeredCameraIDs() + : undefined} + .deviceRegistryManager=${this._controller.getDeviceRegistryManager()} + > + ${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()) + : ''} + + ${this._renderMenuStatusContainer('bottom')} + ${this._config?.elements + ? // Elements need to render after the main views so it can render 'on + // top'. + html` ) => { + this._menuButtonController.addDynamicMenuButton(ev.detail); + this.requestUpdate(); + }} + @advanced-camera-card:menu:remove=${(ev: CustomEvent) => { + this._menuButtonController.removeDynamicMenuButton(ev.detail); + this.requestUpdate(); + }} + @advanced-camera-card:status-bar:add=${( + ev: CustomEvent, + ) => { + this._controller + .getStatusBarItemManager() + .addDynamicStatusBarItem(ev.detail); + }} + @advanced-camera-card:status-bar:remove=${( + ev: CustomEvent, + ) => { + this._controller + .getStatusBarItemManager() + .removeDynamicStatusBarItem(ev.detail); + }} + @advanced-camera-card:condition-state-manager:get=${( + ev: ConditionStateManagerGetEvent, + ) => { + ev.conditionStateManager = this._controller.getConditionStateManager(); + }} + > + ` + : ``} + `, ); } diff --git a/src/components-lib/effects/effects-controller.ts b/src/components-lib/effects/effects-controller.ts new file mode 100644 index 00000000..7f8c4df4 --- /dev/null +++ b/src/components-lib/effects/effects-controller.ts @@ -0,0 +1,98 @@ +import { EffectName, EffectsControllerAPI } from '../../types'; +import { EffectComponent, EffectModule, EffectOptions } from './types'; + +const effectRegistry: Record Promise> = { + 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 = new Map(); + private _activeInstances: Map = new Map(); + private _container: EffectsContainer | null = null; + + public setContainer(container: EffectsContainer | null): void { + this._container = container; + } + + public async startEffect(name: EffectName, options?: EffectOptions): Promise { + 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 { + 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 { + if (this._activeInstances.has(name)) { + await this.stopEffect(name); + } else { + await this.startEffect(name, options); + } + } + + private async _importEffectModule(name: EffectName): Promise { + 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; + } +} diff --git a/src/components-lib/effects/types.ts b/src/components-lib/effects/types.ts new file mode 100644 index 00000000..29468bc9 --- /dev/null +++ b/src/components-lib/effects/types.ts @@ -0,0 +1,10 @@ +export type EffectComponent = HTMLElement & { + fadeIn: boolean; + startFadeOut(): Promise; +}; + +export type EffectModule = { default: new () => EffectComponent }; + +export interface EffectOptions { + fadeIn?: boolean; +} diff --git a/src/components/effects/base.ts b/src/components/effects/base.ts new file mode 100644 index 00000000..83ff602e --- /dev/null +++ b/src/components/effects/base.ts @@ -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 { + 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); + } +} diff --git a/src/components/effects/effects.ts b/src/components/effects/effects.ts new file mode 100644 index 00000000..5c626db9 --- /dev/null +++ b/src/components/effects/effects.ts @@ -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 { + await this._controller.startEffect(effect, options); + } + + public stopEffect(effect: EffectName): void { + this._controller.stopEffect(effect); + } + + public async toggleEffect(effect: EffectName, options?: EffectOptions): Promise { + 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; + } +} diff --git a/src/components/effects/firework-burst.ts b/src/components/effects/firework-burst.ts new file mode 100644 index 00000000..129e3153 --- /dev/null +++ b/src/components/effects/firework-burst.ts @@ -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` + + `, + )} + `; + } + + 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; + } +} diff --git a/src/components/effects/firework-particle.ts b/src/components/effects/firework-particle.ts new file mode 100644 index 00000000..b4a94901 --- /dev/null +++ b/src/components/effects/firework-particle.ts @@ -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`✦`; + } + + 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; + } +} diff --git a/src/components/effects/fireworks.ts b/src/components/effects/fireworks.ts new file mode 100644 index 00000000..0f02799c --- /dev/null +++ b/src/components/effects/fireworks.ts @@ -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` + + `, + )} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-effect-fireworks': AdvancedCameraCardEffectFireworks; + } +} diff --git a/src/components/effects/ghost.ts b/src/components/effects/ghost.ts new file mode 100644 index 00000000..8d93c56e --- /dev/null +++ b/src/components/effects/ghost.ts @@ -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`👻`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(ghostStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-effect-ghost': AdvancedCameraCardEffectGhost; + } +} diff --git a/src/components/effects/heart.ts b/src/components/effects/heart.ts new file mode 100644 index 00000000..f78d21b8 --- /dev/null +++ b/src/components/effects/heart.ts @@ -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; + } +} diff --git a/src/components/effects/hearts.ts b/src/components/effects/hearts.ts new file mode 100644 index 00000000..cce8b104 --- /dev/null +++ b/src/components/effects/hearts.ts @@ -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` + + `, + )} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-effect-hearts': AdvancedCameraCardEffectHearts; + } +} diff --git a/src/components/effects/shamrock.ts b/src/components/effects/shamrock.ts new file mode 100644 index 00000000..71f2f933 --- /dev/null +++ b/src/components/effects/shamrock.ts @@ -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; + } +} diff --git a/src/components/effects/shamrocks.ts b/src/components/effects/shamrocks.ts new file mode 100644 index 00000000..96aa77f8 --- /dev/null +++ b/src/components/effects/shamrocks.ts @@ -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` + + `, + )} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-effect-shamrocks': AdvancedCameraCardEffectShamrocks; + } +} diff --git a/src/components/effects/snow.ts b/src/components/effects/snow.ts new file mode 100644 index 00000000..e3286584 --- /dev/null +++ b/src/components/effects/snow.ts @@ -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` + + `, + )} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-effect-snow': AdvancedCameraCardEffectSnow; + } +} diff --git a/src/components/effects/snowflake.ts b/src/components/effects/snowflake.ts new file mode 100644 index 00000000..7a9c5493 --- /dev/null +++ b/src/components/effects/snowflake.ts @@ -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; + } +} diff --git a/src/components/loading.ts b/src/components/loading.ts index 7fc57a94..bf7a75a6 100644 --- a/src/components/loading.ts +++ b/src/components/loading.ts @@ -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 = { + // 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`; + +const effectActionSchema = z.enum(['start', 'stop', 'toggle']); +export type EffectAction = z.infer; + +export const effectActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend( + { + advanced_camera_card_action: z.literal('effect'), + effect: effectNameSchema, + effect_action: effectActionSchema.default('toggle'), + }, +); + +export type EffectActionConfig = z.infer; diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index 5ac4464d..d8665e60 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -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, diff --git a/src/config/schema/performance.ts b/src/config/schema/performance.ts index e9f4c878..b47aa734 100644 --- a/src/config/schema/performance.ts +++ b/src/config/schema/performance.ts @@ -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), diff --git a/src/const.ts b/src/const.ts index 7b495e45..c5b2e426 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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`; diff --git a/src/editor.ts b/src/editor.ts index cab20a89..74a93587 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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, diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 36942956..2ea66670 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -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": "", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 3636ae05..f213cd28 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index ab7a3ff9..6ba83854 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -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", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index f580eb4d..14ad3368 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -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": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 839073ed..599c56df 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -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": "", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index abdd990b..bb621074 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -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": "", diff --git a/src/scss/effect-base.scss b/src/scss/effect-base.scss new file mode 100644 index 00000000..a0b4748b --- /dev/null +++ b/src/scss/effect-base.scss @@ -0,0 +1,6 @@ +:host { + position: absolute; + inset: 0; + opacity: 1; + transition: opacity 1.5s ease-in; +} diff --git a/src/scss/effects.scss b/src/scss/effects.scss new file mode 100644 index 00000000..5ff4ee67 --- /dev/null +++ b/src/scss/effects.scss @@ -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}; +} diff --git a/src/scss/firework-burst.scss b/src/scss/firework-burst.scss new file mode 100644 index 00000000..266a5a1c --- /dev/null +++ b/src/scss/firework-burst.scss @@ -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; + } +} diff --git a/src/scss/firework-particle.scss b/src/scss/firework-particle.scss new file mode 100644 index 00000000..ca5981da --- /dev/null +++ b/src/scss/firework-particle.scss @@ -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; + } +} diff --git a/src/scss/ghost.scss b/src/scss/ghost.scss new file mode 100644 index 00000000..28ee600e --- /dev/null +++ b/src/scss/ghost.scss @@ -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); + } +} diff --git a/src/scss/heart.scss b/src/scss/heart.scss new file mode 100644 index 00000000..a534edcb --- /dev/null +++ b/src/scss/heart.scss @@ -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; + } +} diff --git a/src/scss/hearts.scss b/src/scss/hearts.scss new file mode 100644 index 00000000..71fd53ac --- /dev/null +++ b/src/scss/hearts.scss @@ -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; + } +} diff --git a/src/scss/shamrock.scss b/src/scss/shamrock.scss new file mode 100644 index 00000000..b9612696 --- /dev/null +++ b/src/scss/shamrock.scss @@ -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; + } +} diff --git a/src/scss/snow.scss b/src/scss/snow.scss new file mode 100644 index 00000000..960509d4 --- /dev/null +++ b/src/scss/snow.scss @@ -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; + } +} diff --git a/src/scss/snowflake.scss b/src/scss/snowflake.scss new file mode 100644 index 00000000..7d0cb41a --- /dev/null +++ b/src/scss/snowflake.scss @@ -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; + } +} diff --git a/src/scss/z-index.scss b/src/scss/z-index.scss index 76ccfac9..18f26221 100644 --- a/src/scss/z-index.scss +++ b/src/scss/z-index.scss @@ -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; diff --git a/src/types.ts b/src/types.ts index f3978530..3ed0e36b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; + +export type EffectName = 'fireworks' | 'ghost' | 'hearts' | 'shamrocks' | 'snow'; + +export interface EffectsControllerAPI { + startEffect(name: EffectName, options?: EffectOptions): Promise; + stopEffect(effect: EffectName): void; + toggleEffect(effect: EffectName, options?: EffectOptions): Promise; +} diff --git a/src/utils/action.ts b/src/utils/action.ts index 3fc84368..1d2aee2b 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -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` diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 6fed3439..dd63b8f1 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -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; +}; diff --git a/tests/card-controller/actions/actions/effect.test.ts b/tests/card-controller/actions/actions/effect.test.ts new file mode 100644 index 00000000..479b477a --- /dev/null +++ b/tests/card-controller/actions/actions/effect.test.ts @@ -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(); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 2699a745..27c3507d 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -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, classObject: object) => { diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 7c28644c..d9f280f3 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -152,6 +152,7 @@ describe('ConfigManager', () => { features: { animated_progress_indicator: true, card_loading_indicator: true, + card_loading_effects: true, media_chunk_size: 50, }, style: { diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 8575d46f..b0cf8ac4 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -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(); + 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', () => { diff --git a/tests/components-lib/effects/effects-controller.test.ts b/tests/components-lib/effects/effects-controller.test.ts new file mode 100644 index 00000000..a8823f37 --- /dev/null +++ b/tests/components-lib/effects/effects-controller.test.ts @@ -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); + }); + }); +}); diff --git a/tests/config/profiles/low-performance.test.ts b/tests/config/profiles/low-performance.test.ts index a12efd22..89aaa56d 100644 --- a/tests/config/profiles/low-performance.test.ts +++ b/tests/config/profiles/low-performance.test.ts @@ -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, diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index e5b7de7d..6b56f723 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -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, }, diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 64756ec7..5e16b4ce 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -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()); api.getConditionStateManager.mockReturnValue(mock()); api.getConfigManager.mockReturnValue(mock()); + api.getEffectsControllerAPI.mockReturnValue(mock()); api.getEntityRegistryManager.mockReturnValue(mock()); api.getExpandManager.mockReturnValue(mock()); api.getFoldersManager.mockReturnValue(mock()); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 97921ded..df3308b1 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -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'); diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 0220eabf..2f78fa9e 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -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(); + }); +});