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:
Dermot Duffy
2025-12-06 18:21:44 -08:00
committed by GitHub
parent 993e288c14
commit 3279481b0e
58 changed files with 1943 additions and 104 deletions
@@ -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
```
+3 -1
View File
@@ -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;
}
}
}
+3
View File
@@ -10,6 +10,7 @@ import { DefaultAction } from './actions/default';
import { DisplayModeSelectAction } from './actions/display-mode-select';
import { DownloadAction } from './actions/download';
import { ExpandAction } from './actions/expand';
import { 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':
+11
View File
@@ -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;
}
+2
View File
@@ -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;
+17 -4
View File
@@ -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,7 +348,10 @@ 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
html` <advanced-camera-card-effects
${ref(this._refEffects)}
></advanced-camera-card-effects>
<ha-card
id="ha-card"
.actionHandler=${actionHandler({
hasHold: hasAction(actions.hold_action),
@@ -370,7 +377,13 @@ class AdvancedCameraCard extends LitElement {
>
${showLoading
? html`<advanced-camera-card-loading
?loaded=${this._controller.getInitializationManager().wasEverInitialized()}
.loaded=${this._controller
.getInitializationManager()
.wasEverInitialized()}
.effectsControllerAPI=${this._config?.performance?.features
.card_loading_effects !== false
? this._controller.getEffectsControllerAPI()
: undefined}
></advanced-camera-card-loading>`
: ''}
${this._renderMenuStatusContainer('top')}
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
export type EffectComponent = HTMLElement & {
fadeIn: boolean;
startFadeOut(): Promise<void>;
};
export type EffectModule = { default: new () => EffectComponent };
export interface EffectOptions {
fadeIn?: boolean;
}
+53
View File
@@ -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);
}
}
+40
View File
@@ -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;
}
}
+172
View File
@@ -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;
}
}
+115
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
}
+79
View File
@@ -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;
}
}
+83
View File
@@ -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;
}
}
+67
View File
@@ -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;
}
}
+69
View File
@@ -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;
}
}
+68
View File
@@ -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;
}
}
+50
View File
@@ -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;
}
}
+66 -1
View File
@@ -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' }}
+2
View File
@@ -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>;
+2
View File
@@ -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
View File
@@ -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),
+1
View File
@@ -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`;
+5
View File
@@ -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,
+1
View File
@@ -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": "",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "",
+1
View File
@@ -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": "",
+1
View File
@@ -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": "",
+6
View File
@@ -0,0 +1,6 @@
:host {
position: absolute;
inset: 0;
opacity: 1;
transition: opacity 1.5s ease-in;
}
+12
View File
@@ -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};
}
+18
View File
@@ -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;
}
}
+40
View File
@@ -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;
}
}
+35
View File
@@ -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);
}
}
+36
View File
@@ -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;
}
}
+36
View File
@@ -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;
}
}
+42
View File
@@ -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;
}
}
+48
View File
@@ -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;
}
}
+48
View File
@@ -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;
}
}
+1
View File
@@ -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
View File
@@ -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>;
}
+21
View File
@@ -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`
+5
View File
@@ -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: {
+13 -3
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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>());
+17
View File
@@ -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');
+14
View File
@@ -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();
});
});