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
@@ -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;
+110 -97
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,104 +348,113 @@ class AdvancedCameraCard extends LitElement {
// Caution: Keep the main div and the menu next to one another in order to
// ensure the hover menu styling continues to work.
return this._renderInDialogIfNecessary(
html` <ha-card
id="ha-card"
.actionHandler=${actionHandler({
hasHold: hasAction(actions.hold_action),
hasDoubleClick: hasAction(actions.double_tap_action),
})}
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
@advanced-camera-card:media:unloaded=${() =>
this._controller.getMediaLoadedInfoManager().clear()}
@advanced-camera-card:media:volumechange=${
() => this.requestUpdate() /* Refresh mute menu button */
}
@advanced-camera-card:media:play=${
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:media:pause=${
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:focus=${() => this.focus()}
>
${showLoading
? html`<advanced-camera-card-loading
?loaded=${this._controller.getInitializationManager().wasEverInitialized()}
></advanced-camera-card-loading>`
: ''}
${this._renderMenuStatusContainer('top')}
${this._renderMenuStatusContainer('overlay')}
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
<advanced-camera-card-views
${ref(this._refViews)}
.hass=${this._hass}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.foldersManager=${this._controller.getFoldersManager()}
.viewItemManager=${this._controller.getViewItemManager()}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.config=${this._controller.getConfigManager().getConfig()}
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
.configManager=${this._controller.getConfigManager()}
.hide=${!!this._controller.getMessageManager().hasMessage()}
.microphoneState=${this._controller.getMicrophoneManager().getState()}
.conditionStateManager=${this._controller.getConditionStateManager()}
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs()
: undefined}
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
></advanced-camera-card-views>
${this._controller.getMessageManager().hasMessage()
? // Keep message rendering to last to show messages that may have been
// generated during the render.
renderMessage(this._controller.getMessageManager().getMessage())
html` <advanced-camera-card-effects
${ref(this._refEffects)}
></advanced-camera-card-effects>
<ha-card
id="ha-card"
.actionHandler=${actionHandler({
hasHold: hasAction(actions.hold_action),
hasDoubleClick: hasAction(actions.double_tap_action),
})}
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
@advanced-camera-card:media:unloaded=${() =>
this._controller.getMediaLoadedInfoManager().clear()}
@advanced-camera-card:media:volumechange=${
() => this.requestUpdate() /* Refresh mute menu button */
}
@advanced-camera-card:media:play=${
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:media:pause=${
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:focus=${() => this.focus()}
>
${showLoading
? html`<advanced-camera-card-loading
.loaded=${this._controller
.getInitializationManager()
.wasEverInitialized()}
.effectsControllerAPI=${this._config?.performance?.features
.card_loading_effects !== false
? this._controller.getEffectsControllerAPI()
: undefined}
></advanced-camera-card-loading>`
: ''}
</div>
${this._renderMenuStatusContainer('bottom')}
${this._config?.elements
? // Elements need to render after the main views so it can render 'on
// top'.
html` <advanced-camera-card-elements
${ref(this._refElements)}
${this._renderMenuStatusContainer('top')}
${this._renderMenuStatusContainer('overlay')}
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
<advanced-camera-card-views
${ref(this._refViews)}
.hass=${this._hass}
.elements=${this._config?.elements}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.foldersManager=${this._controller.getFoldersManager()}
.viewItemManager=${this._controller.getViewItemManager()}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.config=${this._controller.getConfigManager().getConfig()}
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
.configManager=${this._controller.getConfigManager()}
.hide=${!!this._controller.getMessageManager().hasMessage()}
.microphoneState=${this._controller.getMicrophoneManager().getState()}
.conditionStateManager=${this._controller.getConditionStateManager()}
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.addDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@advanced-camera-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.removeDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@advanced-camera-card:status-bar:add=${(
ev: CustomEvent<StatusBarItem>,
) => {
this._controller
.getStatusBarItemManager()
.addDynamicStatusBarItem(ev.detail);
}}
@advanced-camera-card:status-bar:remove=${(
ev: CustomEvent<StatusBarItem>,
) => {
this._controller
.getStatusBarItemManager()
.removeDynamicStatusBarItem(ev.detail);
}}
@advanced-camera-card:condition-state-manager:get=${(
ev: ConditionStateManagerGetEvent,
) => {
ev.conditionStateManager = this._controller.getConditionStateManager();
}}
>
</advanced-camera-card-elements>`
: ``}
</ha-card>`,
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs()
: undefined}
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
></advanced-camera-card-views>
${this._controller.getMessageManager().hasMessage()
? // Keep message rendering to last to show messages that may have been
// generated during the render.
renderMessage(this._controller.getMessageManager().getMessage())
: ''}
</div>
${this._renderMenuStatusContainer('bottom')}
${this._config?.elements
? // Elements need to render after the main views so it can render 'on
// top'.
html` <advanced-camera-card-elements
${ref(this._refElements)}
.hass=${this._hass}
.elements=${this._config?.elements}
.conditionStateManager=${this._controller.getConditionStateManager()}
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.addDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@advanced-camera-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.removeDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@advanced-camera-card:status-bar:add=${(
ev: CustomEvent<StatusBarItem>,
) => {
this._controller
.getStatusBarItemManager()
.addDynamicStatusBarItem(ev.detail);
}}
@advanced-camera-card:status-bar:remove=${(
ev: CustomEvent<StatusBarItem>,
) => {
this._controller
.getStatusBarItemManager()
.removeDynamicStatusBarItem(ev.detail);
}}
@advanced-camera-card:condition-state-manager:get=${(
ev: ConditionStateManagerGetEvent,
) => {
ev.conditionStateManager = this._controller.getConditionStateManager();
}}
>
</advanced-camera-card-elements>`
: ``}
</ha-card>`,
);
}
@@ -0,0 +1,98 @@
import { EffectName, EffectsControllerAPI } from '../../types';
import { EffectComponent, EffectModule, EffectOptions } from './types';
const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = {
fireworks: async () => {
const module = await import('../../components/effects/fireworks');
return { default: module.AdvancedCameraCardEffectFireworks };
},
ghost: async () => {
const module = await import('../../components/effects/ghost');
return { default: module.AdvancedCameraCardEffectGhost };
},
hearts: async () => {
const module = await import('../../components/effects/hearts');
return { default: module.AdvancedCameraCardEffectHearts };
},
shamrocks: async () => {
const module = await import('../../components/effects/shamrocks');
return { default: module.AdvancedCameraCardEffectShamrocks };
},
snow: async () => {
const module = await import('../../components/effects/snow');
return { default: module.AdvancedCameraCardEffectSnow };
},
};
type EffectsContainer = HTMLElement | DocumentFragment;
export class EffectsController implements EffectsControllerAPI {
private _importedModules: Map<EffectName, EffectModule> = new Map();
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
private _container: EffectsContainer | null = null;
public setContainer(container: EffectsContainer | null): void {
this._container = container;
}
public async startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
if (!this._container || this._activeInstances.has(name)) {
return;
}
// Reserve the slot immediately with null to prevent concurrent starts.
this._activeInstances.set(name, null);
const effectModule = await this._importEffectModule(name);
// Check if the effect was cancelled during loading.
if (!effectModule || !this._activeInstances.has(name)) {
this._activeInstances.delete(name);
return;
}
const effectComponent = new effectModule.default();
effectComponent.fadeIn = options?.fadeIn ?? true;
this._container.appendChild(effectComponent);
this._activeInstances.set(name, effectComponent);
}
public async stopEffect(effect: EffectName): Promise<void> {
if (!this._activeInstances.has(effect)) {
return;
}
const instance = this._activeInstances.get(effect);
this._activeInstances.delete(effect);
// If instance is null, it's still loading - just clearing the reservation
// will prevent it from appearing (startEffect checks this after import).
if (instance) {
await instance.startFadeOut();
instance.remove();
}
}
public async toggleEffect(name: EffectName, options?: EffectOptions): Promise<void> {
if (this._activeInstances.has(name)) {
await this.stopEffect(name);
} else {
await this.startEffect(name, options);
}
}
private async _importEffectModule(name: EffectName): Promise<EffectModule | null> {
const existingModule = this._importedModules.get(name);
if (existingModule) {
return existingModule;
}
const effectModule = await effectRegistry[name]?.();
if (!effectModule) {
return null;
}
this._importedModules.set(name, effectModule);
return effectModule;
}
}
+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;
};