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:
@@ -0,0 +1,21 @@
|
||||
import { EffectActionConfig } from '../../../config/schema/actions/custom/effect';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
switch (this._action.effect_action) {
|
||||
case 'start':
|
||||
api.getEffectsControllerAPI()?.startEffect(this._action.effect);
|
||||
break;
|
||||
case 'stop':
|
||||
api.getEffectsControllerAPI()?.stopEffect(this._action.effect);
|
||||
break;
|
||||
case 'toggle':
|
||||
api.getEffectsControllerAPI()?.toggleEffect(this._action.effect);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { DefaultAction } from './actions/default';
|
||||
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
||||
import { DownloadAction } from './actions/download';
|
||||
import { ExpandAction } from './actions/expand';
|
||||
import { EffectAction } from './actions/effect';
|
||||
import { FullscreenAction } from './actions/fullscreen';
|
||||
import { InternalCallbackAction } from './actions/internal-callback';
|
||||
import { LogAction } from './actions/log';
|
||||
@@ -103,6 +104,8 @@ export class ActionFactory {
|
||||
return new DownloadAction(context, action, options?.config);
|
||||
case 'camera_ui':
|
||||
return new CameraUIAction(context, action, options?.config);
|
||||
case 'effect':
|
||||
return new EffectAction(context, action, options?.config);
|
||||
case 'expand':
|
||||
return new ExpandAction(context, action, options?.config);
|
||||
case 'fullscreen':
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EntityRegistryManagerLive } from '../ha/registry/entity';
|
||||
import { EntityCache, EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import { LovelaceCardEditor } from '../ha/types';
|
||||
import { EffectsControllerAPI } from '../types';
|
||||
import { ActionsManager } from './actions/actions-manager';
|
||||
import { AutomationsManager } from './automations-manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
@@ -63,6 +64,8 @@ import {
|
||||
import { ViewItemManager } from './view/item-manager';
|
||||
import { ViewManager } from './view/view-manager';
|
||||
|
||||
type EffectsControllerAPICallback = () => EffectsControllerAPI | null;
|
||||
|
||||
export class CardController
|
||||
implements
|
||||
CardActionsManagerAPI,
|
||||
@@ -90,6 +93,8 @@ export class CardController
|
||||
CardViewAPI,
|
||||
ReactiveController
|
||||
{
|
||||
protected _effectsControllerAPICallback: EffectsControllerAPICallback;
|
||||
|
||||
protected _conditionStateManager = new ConditionStateManager();
|
||||
|
||||
// These properties may be used in the construction of 'managers' (and should
|
||||
@@ -127,6 +132,7 @@ export class CardController
|
||||
host: CardHTMLElement,
|
||||
scrollCallback: ScrollCallback,
|
||||
menuToggleCallback: MenuToggleCallback,
|
||||
effectsControllerAPICallback: EffectsControllerAPICallback,
|
||||
) {
|
||||
host.addController(this);
|
||||
|
||||
@@ -136,6 +142,7 @@ export class CardController
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
);
|
||||
this._effectsControllerAPICallback = effectsControllerAPICallback;
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
@@ -186,6 +193,10 @@ export class CardController
|
||||
return this._deviceRegistryManager;
|
||||
}
|
||||
|
||||
public getEffectsControllerAPI(): EffectsControllerAPI | null {
|
||||
return this._effectsControllerAPICallback();
|
||||
}
|
||||
|
||||
public getEntityRegistryManager(): EntityRegistryManager {
|
||||
return this._entityRegistryManager;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ConditionStateManager } from '../conditions/state-manager';
|
||||
import type { Automation } from '../config/schema/automations';
|
||||
import type { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import type { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import type { EffectsControllerAPI } from '../types';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
@@ -41,6 +42,7 @@ export interface CardActionsAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getEffectsControllerAPI(): EffectsControllerAPI | null;
|
||||
getExpandManager(): ExpandManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
|
||||
+110
-97
@@ -8,6 +8,8 @@ import 'web-dialog';
|
||||
import { actionHandler } from './action-handler-directive.js';
|
||||
import { CardController } from './card-controller/controller';
|
||||
import { MenuButtonController } from './components-lib/menu-button-controller';
|
||||
import './components/effects/effects';
|
||||
import { AdvancedCameraCardEffects } from './components/effects/effects';
|
||||
import './components/elements.js';
|
||||
import { AdvancedCameraCardElements } from './components/elements.js';
|
||||
import './components/loading.js';
|
||||
@@ -93,14 +95,16 @@ class AdvancedCameraCard extends LitElement {
|
||||
// diagnostics starting at the top).
|
||||
() => this._refMain.value?.scroll({ top: 0 }),
|
||||
() => this._refMenu.value?.toggleMenu(),
|
||||
() => this._refEffects.value ?? null,
|
||||
);
|
||||
|
||||
protected _menuButtonController = new MenuButtonController();
|
||||
|
||||
protected _refEffects: Ref<AdvancedCameraCardEffects> = createRef();
|
||||
protected _refElements: Ref<AdvancedCameraCardElements> = createRef();
|
||||
protected _refMain: Ref<HTMLElement> = createRef();
|
||||
protected _refMenu: Ref<AdvancedCameraCardMenu> = createRef();
|
||||
protected _refOverlay: Ref<AdvancedCameraCardOverlay> = createRef();
|
||||
protected _refMain: Ref<HTMLElement> = createRef();
|
||||
protected _refElements: Ref<AdvancedCameraCardElements> = createRef();
|
||||
protected _refViews: Ref<AdvancedCameraCardViews> = createRef();
|
||||
|
||||
// Convenience methods for very frequently accessed attributes.
|
||||
@@ -344,104 +348,113 @@ class AdvancedCameraCard extends LitElement {
|
||||
// Caution: Keep the main div and the menu next to one another in order to
|
||||
// ensure the hover menu styling continues to work.
|
||||
return this._renderInDialogIfNecessary(
|
||||
html` <ha-card
|
||||
id="ha-card"
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(actions.hold_action),
|
||||
hasDoubleClick: hasAction(actions.double_tap_action),
|
||||
})}
|
||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||
@advanced-camera-card:media:unloaded=${() =>
|
||||
this._controller.getMediaLoadedInfoManager().clear()}
|
||||
@advanced-camera-card:media:volumechange=${
|
||||
() => this.requestUpdate() /* Refresh mute menu button */
|
||||
}
|
||||
@advanced-camera-card:media:play=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:media:pause=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:focus=${() => this.focus()}
|
||||
>
|
||||
${showLoading
|
||||
? html`<advanced-camera-card-loading
|
||||
?loaded=${this._controller.getInitializationManager().wasEverInitialized()}
|
||||
></advanced-camera-card-loading>`
|
||||
: ''}
|
||||
${this._renderMenuStatusContainer('top')}
|
||||
${this._renderMenuStatusContainer('overlay')}
|
||||
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
|
||||
<advanced-camera-card-views
|
||||
${ref(this._refViews)}
|
||||
.hass=${this._hass}
|
||||
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||
.cameraManager=${cameraManager}
|
||||
.foldersManager=${this._controller.getFoldersManager()}
|
||||
.viewItemManager=${this._controller.getViewItemManager()}
|
||||
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
||||
.config=${this._controller.getConfigManager().getConfig()}
|
||||
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
|
||||
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
|
||||
.configManager=${this._controller.getConfigManager()}
|
||||
.hide=${!!this._controller.getMessageManager().hasMessage()}
|
||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||
: undefined}
|
||||
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
|
||||
></advanced-camera-card-views>
|
||||
${this._controller.getMessageManager().hasMessage()
|
||||
? // Keep message rendering to last to show messages that may have been
|
||||
// generated during the render.
|
||||
renderMessage(this._controller.getMessageManager().getMessage())
|
||||
html` <advanced-camera-card-effects
|
||||
${ref(this._refEffects)}
|
||||
></advanced-camera-card-effects>
|
||||
<ha-card
|
||||
id="ha-card"
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(actions.hold_action),
|
||||
hasDoubleClick: hasAction(actions.double_tap_action),
|
||||
})}
|
||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||
@advanced-camera-card:media:unloaded=${() =>
|
||||
this._controller.getMediaLoadedInfoManager().clear()}
|
||||
@advanced-camera-card:media:volumechange=${
|
||||
() => this.requestUpdate() /* Refresh mute menu button */
|
||||
}
|
||||
@advanced-camera-card:media:play=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:media:pause=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@advanced-camera-card:focus=${() => this.focus()}
|
||||
>
|
||||
${showLoading
|
||||
? html`<advanced-camera-card-loading
|
||||
.loaded=${this._controller
|
||||
.getInitializationManager()
|
||||
.wasEverInitialized()}
|
||||
.effectsControllerAPI=${this._config?.performance?.features
|
||||
.card_loading_effects !== false
|
||||
? this._controller.getEffectsControllerAPI()
|
||||
: undefined}
|
||||
></advanced-camera-card-loading>`
|
||||
: ''}
|
||||
</div>
|
||||
${this._renderMenuStatusContainer('bottom')}
|
||||
${this._config?.elements
|
||||
? // Elements need to render after the main views so it can render 'on
|
||||
// top'.
|
||||
html` <advanced-camera-card-elements
|
||||
${ref(this._refElements)}
|
||||
${this._renderMenuStatusContainer('top')}
|
||||
${this._renderMenuStatusContainer('overlay')}
|
||||
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
|
||||
<advanced-camera-card-views
|
||||
${ref(this._refViews)}
|
||||
.hass=${this._hass}
|
||||
.elements=${this._config?.elements}
|
||||
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||
.cameraManager=${cameraManager}
|
||||
.foldersManager=${this._controller.getFoldersManager()}
|
||||
.viewItemManager=${this._controller.getViewItemManager()}
|
||||
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
|
||||
.config=${this._controller.getConfigManager().getConfig()}
|
||||
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
|
||||
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
|
||||
.configManager=${this._controller.getConfigManager()}
|
||||
.hide=${!!this._controller.getMessageManager().hasMessage()}
|
||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.addDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.removeDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:status-bar:add=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.addDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:status-bar:remove=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.removeDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:condition-state-manager:get=${(
|
||||
ev: ConditionStateManagerGetEvent,
|
||||
) => {
|
||||
ev.conditionStateManager = this._controller.getConditionStateManager();
|
||||
}}
|
||||
>
|
||||
</advanced-camera-card-elements>`
|
||||
: ``}
|
||||
</ha-card>`,
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||
: undefined}
|
||||
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
|
||||
></advanced-camera-card-views>
|
||||
${this._controller.getMessageManager().hasMessage()
|
||||
? // Keep message rendering to last to show messages that may have been
|
||||
// generated during the render.
|
||||
renderMessage(this._controller.getMessageManager().getMessage())
|
||||
: ''}
|
||||
</div>
|
||||
${this._renderMenuStatusContainer('bottom')}
|
||||
${this._config?.elements
|
||||
? // Elements need to render after the main views so it can render 'on
|
||||
// top'.
|
||||
html` <advanced-camera-card-elements
|
||||
${ref(this._refElements)}
|
||||
.hass=${this._hass}
|
||||
.elements=${this._config?.elements}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.addDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
|
||||
this._menuButtonController.removeDynamicMenuButton(ev.detail);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@advanced-camera-card:status-bar:add=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.addDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:status-bar:remove=${(
|
||||
ev: CustomEvent<StatusBarItem>,
|
||||
) => {
|
||||
this._controller
|
||||
.getStatusBarItemManager()
|
||||
.removeDynamicStatusBarItem(ev.detail);
|
||||
}}
|
||||
@advanced-camera-card:condition-state-manager:get=${(
|
||||
ev: ConditionStateManagerGetEvent,
|
||||
) => {
|
||||
ev.conditionStateManager = this._controller.getConditionStateManager();
|
||||
}}
|
||||
>
|
||||
</advanced-camera-card-elements>`
|
||||
: ``}
|
||||
</ha-card>`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EffectName, EffectsControllerAPI } from '../../types';
|
||||
import { EffectComponent, EffectModule, EffectOptions } from './types';
|
||||
|
||||
const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = {
|
||||
fireworks: async () => {
|
||||
const module = await import('../../components/effects/fireworks');
|
||||
return { default: module.AdvancedCameraCardEffectFireworks };
|
||||
},
|
||||
ghost: async () => {
|
||||
const module = await import('../../components/effects/ghost');
|
||||
return { default: module.AdvancedCameraCardEffectGhost };
|
||||
},
|
||||
hearts: async () => {
|
||||
const module = await import('../../components/effects/hearts');
|
||||
return { default: module.AdvancedCameraCardEffectHearts };
|
||||
},
|
||||
shamrocks: async () => {
|
||||
const module = await import('../../components/effects/shamrocks');
|
||||
return { default: module.AdvancedCameraCardEffectShamrocks };
|
||||
},
|
||||
snow: async () => {
|
||||
const module = await import('../../components/effects/snow');
|
||||
return { default: module.AdvancedCameraCardEffectSnow };
|
||||
},
|
||||
};
|
||||
|
||||
type EffectsContainer = HTMLElement | DocumentFragment;
|
||||
|
||||
export class EffectsController implements EffectsControllerAPI {
|
||||
private _importedModules: Map<EffectName, EffectModule> = new Map();
|
||||
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
|
||||
private _container: EffectsContainer | null = null;
|
||||
|
||||
public setContainer(container: EffectsContainer | null): void {
|
||||
this._container = container;
|
||||
}
|
||||
|
||||
public async startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
|
||||
if (!this._container || this._activeInstances.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve the slot immediately with null to prevent concurrent starts.
|
||||
this._activeInstances.set(name, null);
|
||||
|
||||
const effectModule = await this._importEffectModule(name);
|
||||
|
||||
// Check if the effect was cancelled during loading.
|
||||
if (!effectModule || !this._activeInstances.has(name)) {
|
||||
this._activeInstances.delete(name);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectComponent = new effectModule.default();
|
||||
effectComponent.fadeIn = options?.fadeIn ?? true;
|
||||
this._container.appendChild(effectComponent);
|
||||
this._activeInstances.set(name, effectComponent);
|
||||
}
|
||||
|
||||
public async stopEffect(effect: EffectName): Promise<void> {
|
||||
if (!this._activeInstances.has(effect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = this._activeInstances.get(effect);
|
||||
this._activeInstances.delete(effect);
|
||||
|
||||
// If instance is null, it's still loading - just clearing the reservation
|
||||
// will prevent it from appearing (startEffect checks this after import).
|
||||
if (instance) {
|
||||
await instance.startFadeOut();
|
||||
instance.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public async toggleEffect(name: EffectName, options?: EffectOptions): Promise<void> {
|
||||
if (this._activeInstances.has(name)) {
|
||||
await this.stopEffect(name);
|
||||
} else {
|
||||
await this.startEffect(name, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async _importEffectModule(name: EffectName): Promise<EffectModule | null> {
|
||||
const existingModule = this._importedModules.get(name);
|
||||
if (existingModule) {
|
||||
return existingModule;
|
||||
}
|
||||
|
||||
const effectModule = await effectRegistry[name]?.();
|
||||
if (!effectModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this._importedModules.set(name, effectModule);
|
||||
return effectModule;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type EffectComponent = HTMLElement & {
|
||||
fadeIn: boolean;
|
||||
startFadeOut(): Promise<void>;
|
||||
};
|
||||
|
||||
export type EffectModule = { default: new () => EffectComponent };
|
||||
|
||||
export interface EffectOptions {
|
||||
fadeIn?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CSSResultGroup, LitElement, PropertyValues, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import effectBaseStyle from '../../scss/effect-base.scss';
|
||||
import { forceReflow } from '../../utils/basic';
|
||||
|
||||
export abstract class BaseEffectComponent extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public fadeIn = true;
|
||||
|
||||
protected firstUpdated(): void {
|
||||
if (this.fadeIn) {
|
||||
this._startFadeIn();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
// Skip if this is the initial property setting (handled by firstUpdated).
|
||||
if (changedProps.get('fadeIn') !== undefined) {
|
||||
if (!this.fadeIn) {
|
||||
this._setOpacity(1);
|
||||
} else {
|
||||
this._startFadeIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public startFadeOut(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (ev: TransitionEvent) => {
|
||||
if (ev.propertyName === 'opacity') {
|
||||
this.removeEventListener('transitionend', handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.addEventListener('transitionend', handler);
|
||||
this._setOpacity(0);
|
||||
});
|
||||
}
|
||||
|
||||
private _startFadeIn(): void {
|
||||
this._setOpacity(0);
|
||||
forceReflow(this);
|
||||
this._setOpacity(1);
|
||||
}
|
||||
|
||||
private _setOpacity(opacity: number): void {
|
||||
this.style.opacity = `${opacity}`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(effectBaseStyle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { LitElement, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { EffectsController } from '../../components-lib/effects/effects-controller';
|
||||
import { EffectOptions } from '../../components-lib/effects/types';
|
||||
import effectsStyle from '../../scss/effects.scss';
|
||||
import { EffectName, EffectsControllerAPI } from '../../types';
|
||||
|
||||
@customElement('advanced-camera-card-effects')
|
||||
export class AdvancedCameraCardEffects
|
||||
extends LitElement
|
||||
implements EffectsControllerAPI
|
||||
{
|
||||
protected _controller = new EffectsController();
|
||||
|
||||
public async startEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
|
||||
await this._controller.startEffect(effect, options);
|
||||
}
|
||||
|
||||
public stopEffect(effect: EffectName): void {
|
||||
this._controller.stopEffect(effect);
|
||||
}
|
||||
|
||||
public async toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
|
||||
await this._controller.toggleEffect(effect, options);
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this._controller.setContainer(this.renderRoot);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return unsafeCSS(effectsStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effects': AdvancedCameraCardEffects;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import fireworkBurstStyle from '../../scss/firework-burst.scss';
|
||||
import './firework-particle';
|
||||
|
||||
const BASE_PARTICLE_COUNT = 36;
|
||||
|
||||
const FIREWORK_COLORS = [
|
||||
'#ff2222', // Bright Red
|
||||
'#ffdd00', // Bright Gold
|
||||
'#22ff22', // Bright Green
|
||||
'#22aaff', // Bright Blue
|
||||
'#ff22ff', // Bright Magenta
|
||||
'#ffffff', // White
|
||||
'#ffaa00', // Bright Orange
|
||||
'#22ffff', // Bright Cyan
|
||||
'#ff66aa', // Pink
|
||||
'#aaaaff', // Lavender
|
||||
];
|
||||
|
||||
type BurstType = 'standard' | 'ring' | 'palm';
|
||||
|
||||
interface ParticleConfig {
|
||||
id: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
color: string;
|
||||
size: string;
|
||||
duration: string;
|
||||
delay: string;
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-firework-burst')
|
||||
export class AdvancedCameraCardFireworkBurst extends LitElement {
|
||||
@property({ type: String })
|
||||
public posX = '50%';
|
||||
|
||||
@property({ type: String })
|
||||
public posY = '50%';
|
||||
|
||||
@property({ type: String })
|
||||
public delay = '0s';
|
||||
|
||||
@property({ type: Number })
|
||||
public scale = 1.0;
|
||||
|
||||
@property({ type: String })
|
||||
public burstType: BurstType = 'standard';
|
||||
|
||||
private _particles: ParticleConfig[] = [];
|
||||
private _color: string = '';
|
||||
private _initialized = false;
|
||||
|
||||
private _initializeParticles(): void {
|
||||
if (this._initialized) {
|
||||
return;
|
||||
}
|
||||
this._initialized = true;
|
||||
|
||||
this._color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
|
||||
|
||||
switch (this.burstType) {
|
||||
case 'ring':
|
||||
this._initializeRingParticles();
|
||||
break;
|
||||
case 'palm':
|
||||
this._initializePalmParticles();
|
||||
break;
|
||||
default:
|
||||
this._initializeStandardParticles();
|
||||
}
|
||||
}
|
||||
|
||||
private _initializeStandardParticles(): void {
|
||||
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale);
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
const angleVariation = (Math.random() - 0.5) * 20;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle + angleVariation,
|
||||
distance: (Math.random() * 80 + 100) * this.scale,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 12 + 18) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.5 + 1.8}s`,
|
||||
delay: `${Math.random() * 0.08}s`,
|
||||
gravity: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _initializeRingParticles(): void {
|
||||
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale * 1.5);
|
||||
const ringDistance = (120 + Math.random() * 40) * this.scale;
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle,
|
||||
distance: ringDistance + (Math.random() - 0.5) * 10,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 8 + 14) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.3 + 1.5}s`,
|
||||
delay: `${Math.random() * 0.02}s`,
|
||||
gravity: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _initializePalmParticles(): void {
|
||||
const particleCount = Math.round(18 * this.scale);
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
const angleVariation = (Math.random() - 0.5) * 15;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle + angleVariation,
|
||||
distance: (Math.random() * 60 + 140) * this.scale,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 10 + 20) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.8 + 2.2}s`,
|
||||
delay: `${Math.random() * 0.05}s`,
|
||||
gravity: 80 + Math.random() * 60,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
this._initializeParticles();
|
||||
return html`
|
||||
${repeat(
|
||||
this._particles,
|
||||
(p) => p.id,
|
||||
(p) => html`
|
||||
<advanced-camera-card-firework-particle
|
||||
.angle=${p.angle}
|
||||
.distance=${p.distance}
|
||||
.color=${p.color}
|
||||
.size=${p.size}
|
||||
.duration=${p.duration}
|
||||
.delay=${p.delay}
|
||||
.gravity=${p.gravity}
|
||||
></advanced-camera-card-firework-particle>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--pos-x', this.posX);
|
||||
this.style.setProperty('--pos-y', this.posY);
|
||||
this.style.setProperty('--delay', this.delay);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(fireworkBurstStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-firework-burst': AdvancedCameraCardFireworkBurst;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import fireworkParticleStyle from '../../scss/firework-particle.scss';
|
||||
|
||||
@customElement('advanced-camera-card-firework-particle')
|
||||
export class AdvancedCameraCardFireworkParticle extends LitElement {
|
||||
@property({ type: Number })
|
||||
public angle = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
public distance = 100;
|
||||
|
||||
@property({ type: String })
|
||||
public color = '#ffcc00';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '4px';
|
||||
|
||||
@property({ type: String })
|
||||
public duration = '1.5s';
|
||||
|
||||
@property({ type: String })
|
||||
public delay = '0s';
|
||||
|
||||
@property({ type: Number })
|
||||
public gravity = 0;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<span class="spark">✦</span>`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const radians = (this.angle * Math.PI) / 180;
|
||||
const endX = Math.cos(radians) * this.distance;
|
||||
const endY = Math.sin(radians) * this.distance + this.gravity;
|
||||
|
||||
this.style.setProperty('--end-x', `${endX}px`);
|
||||
this.style.setProperty('--end-y', `${endY}px`);
|
||||
this.style.setProperty('--color', this.color);
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--duration', this.duration);
|
||||
this.style.setProperty('--delay', this.delay);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(fireworkParticleStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-firework-particle': AdvancedCameraCardFireworkParticle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './firework-burst';
|
||||
|
||||
const INITIAL_BURST_COUNT = 3;
|
||||
const MIN_BURST_COUNT = 2;
|
||||
const MAX_BURST_COUNT = 5;
|
||||
const BURST_CYCLE_SECONDS = 2.0;
|
||||
const MAX_BURST_DELAY_SECONDS = 1.2;
|
||||
|
||||
type BurstType = 'standard' | 'ring' | 'palm';
|
||||
|
||||
interface BurstConfig {
|
||||
id: number;
|
||||
posX: string;
|
||||
posY: string;
|
||||
delay: string;
|
||||
scale: number;
|
||||
burstType: BurstType;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-fireworks')
|
||||
export class AdvancedCameraCardEffectFireworks extends BaseEffectComponent {
|
||||
@state()
|
||||
private _bursts: BurstConfig[] = [];
|
||||
|
||||
private _burstIdCounter = 0;
|
||||
private _timer = new Timer();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._startFireworks();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopFireworks();
|
||||
}
|
||||
|
||||
private _startFireworks(): void {
|
||||
this._createBursts(INITIAL_BURST_COUNT);
|
||||
|
||||
this._timer.startRepeated(BURST_CYCLE_SECONDS, () => {
|
||||
const count =
|
||||
Math.floor(Math.random() * (MAX_BURST_COUNT - MIN_BURST_COUNT + 1)) +
|
||||
MIN_BURST_COUNT;
|
||||
this._createBursts(count);
|
||||
});
|
||||
}
|
||||
|
||||
private _stopFireworks(): void {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
private _createBursts(count: number): void {
|
||||
const newBursts: BurstConfig[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newBursts.push(this._createBurstConfig());
|
||||
}
|
||||
this._bursts = newBursts;
|
||||
}
|
||||
|
||||
private _createBurstConfig(): BurstConfig {
|
||||
// 20% chance of a big burst (scale 1.5-2.0), otherwise normal (scale 0.8-1.2)
|
||||
const isBig = Math.random() < 0.2;
|
||||
const scale = isBig ? Math.random() * 0.5 + 1.5 : Math.random() * 0.4 + 0.8;
|
||||
|
||||
// Burst type distribution: 60% standard, 25% ring, 15% palm
|
||||
const typeRoll = Math.random();
|
||||
let burstType: BurstType;
|
||||
if (typeRoll < 0.6) {
|
||||
burstType = 'standard';
|
||||
} else if (typeRoll < 0.85) {
|
||||
burstType = 'ring';
|
||||
} else {
|
||||
burstType = 'palm';
|
||||
}
|
||||
|
||||
return {
|
||||
id: this._burstIdCounter++,
|
||||
posX: `${Math.random() * 80 + 10}%`,
|
||||
posY: `${Math.random() * 60 + 20}%`,
|
||||
delay: `${Math.random() * MAX_BURST_DELAY_SECONDS}s`,
|
||||
scale,
|
||||
burstType,
|
||||
};
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._bursts,
|
||||
(burst) => burst.id,
|
||||
(burst) => html`
|
||||
<advanced-camera-card-firework-burst
|
||||
.posX=${burst.posX}
|
||||
.posY=${burst.posY}
|
||||
.delay=${burst.delay}
|
||||
.scale=${burst.scale}
|
||||
.burstType=${burst.burstType}
|
||||
></advanced-camera-card-firework-burst>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-fireworks': AdvancedCameraCardEffectFireworks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import ghostStyle from '../../scss/ghost.scss';
|
||||
|
||||
@customElement('advanced-camera-card-effect-ghost')
|
||||
export class AdvancedCameraCardEffectGhost extends BaseEffectComponent {
|
||||
protected render(): TemplateResult {
|
||||
return html`<span class="ghost">👻</span>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(ghostStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-ghost': AdvancedCameraCardEffectGhost;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import heartStyle from '../../scss/heart.scss';
|
||||
|
||||
@customElement('advanced-camera-card-heart')
|
||||
export class AdvancedCameraCardHeart extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '❤️';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public hue = 340;
|
||||
|
||||
@property({ type: Number })
|
||||
public saturation = 80;
|
||||
|
||||
@property({ type: Number })
|
||||
public lightness = 55;
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDuration = '3s';
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public startY = '0%';
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
private _handleAnimationIteration = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'pulse') {
|
||||
this.startX = `${Math.random() * 100}%`;
|
||||
this.startY = `${Math.random() * 100}%`;
|
||||
}
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--hue', `${this.hue}`);
|
||||
this.style.setProperty('--saturation', `${this.saturation}`);
|
||||
this.style.setProperty('--lightness', `${this.lightness}`);
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--pulse-duration', this.pulseDuration);
|
||||
this.style.setProperty('--pulse-delay', this.pulseDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--start-y', this.startY);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(heartStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-heart': AdvancedCameraCardHeart;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './heart';
|
||||
|
||||
const HEART_CHARS = ['❤️', '💖', '💕', '💗', '💓'];
|
||||
const HEART_MAX_COUNT = 50;
|
||||
|
||||
interface HeartConfig {
|
||||
id: number;
|
||||
char: string;
|
||||
|
||||
size: string;
|
||||
|
||||
hue: number;
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
|
||||
maxOpacity: number;
|
||||
|
||||
pulseDuration: string;
|
||||
pulseDelay: string;
|
||||
|
||||
startX: string;
|
||||
startY: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-hearts')
|
||||
export class AdvancedCameraCardEffectHearts extends BaseEffectComponent {
|
||||
private _hearts: HeartConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._hearts = Array.from({ length: HEART_MAX_COUNT }, (_, i) => {
|
||||
const duration = Math.random() * 4 + 4;
|
||||
const delay = -Math.random() * duration * 0.8;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
char: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],
|
||||
size: `${Math.random() * 1.5 + 0.5}em`,
|
||||
hue: Math.random() * 40 + 320,
|
||||
saturation: Math.random() * 40 + 60,
|
||||
lightness: Math.random() * 20 + 45,
|
||||
maxOpacity: Math.random() * 0.5 + 0.2,
|
||||
pulseDuration: `${duration}s`,
|
||||
pulseDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 100}%`,
|
||||
startY: `${Math.random() * 100}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._hearts,
|
||||
(heart) => heart.id,
|
||||
(heart) => html`
|
||||
<advanced-camera-card-heart
|
||||
.char=${heart.char}
|
||||
.size=${heart.size}
|
||||
.hue=${heart.hue}
|
||||
.saturation=${heart.saturation}
|
||||
.lightness=${heart.lightness}
|
||||
.maxOpacity=${heart.maxOpacity}
|
||||
.pulseDuration=${heart.pulseDuration}
|
||||
.pulseDelay=${heart.pulseDelay}
|
||||
.startX=${heart.startX}
|
||||
.startY=${heart.startY}
|
||||
></advanced-camera-card-heart>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-hearts': AdvancedCameraCardEffectHearts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import shamrockStyle from '../../scss/shamrock.scss';
|
||||
|
||||
@customElement('advanced-camera-card-shamrock')
|
||||
export class AdvancedCameraCardShamrock extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '☘️';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDuration = '3s';
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public startY = '0%';
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
private _handleAnimationIteration = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'shamrock-pulse') {
|
||||
this.startX = `${Math.random() * 100}%`;
|
||||
this.startY = `${Math.random() * 100}%`;
|
||||
}
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--pulse-duration', this.pulseDuration);
|
||||
this.style.setProperty('--pulse-delay', this.pulseDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--start-y', this.startY);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(shamrockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-shamrock': AdvancedCameraCardShamrock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './shamrock';
|
||||
|
||||
const SHAMROCK_COUNT = 10;
|
||||
|
||||
interface ShamrockConfig {
|
||||
id: number;
|
||||
|
||||
size: string;
|
||||
|
||||
maxOpacity: number;
|
||||
|
||||
pulseDuration: string;
|
||||
pulseDelay: string;
|
||||
|
||||
startX: string;
|
||||
startY: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-shamrocks')
|
||||
export class AdvancedCameraCardEffectShamrocks extends BaseEffectComponent {
|
||||
private _shamrocks: ShamrockConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._shamrocks = Array.from({ length: SHAMROCK_COUNT }, (_, i) => {
|
||||
const duration = Math.random() * 3 + 5;
|
||||
const delay = -Math.random() * duration * 0.9;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
size: `${Math.random() * 12 + 8}em`,
|
||||
maxOpacity: Math.random() * 0.3 + 0.5,
|
||||
pulseDuration: `${duration}s`,
|
||||
pulseDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 80 + 10}%`,
|
||||
startY: `${Math.random() * 80 + 10}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._shamrocks,
|
||||
(shamrock) => shamrock.id,
|
||||
(shamrock) => html`
|
||||
<advanced-camera-card-shamrock
|
||||
.size=${shamrock.size}
|
||||
.maxOpacity=${shamrock.maxOpacity}
|
||||
.pulseDuration=${shamrock.pulseDuration}
|
||||
.pulseDelay=${shamrock.pulseDelay}
|
||||
.startX=${shamrock.startX}
|
||||
.startY=${shamrock.startY}
|
||||
></advanced-camera-card-shamrock>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-shamrocks': AdvancedCameraCardEffectShamrocks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './snowflake';
|
||||
|
||||
const SNOWFLAKE_CHARS = ['❄', '❅', '❆'];
|
||||
const MAX_SNOWFLAKES = 50;
|
||||
|
||||
interface SnowflakeConfig {
|
||||
id: number;
|
||||
char: string;
|
||||
size: string;
|
||||
maxOpacity: number;
|
||||
fallDuration: string;
|
||||
fallDelay: string;
|
||||
startX: string;
|
||||
endX: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-snow')
|
||||
export class AdvancedCameraCardEffectSnow extends BaseEffectComponent {
|
||||
private _snowflakes: SnowflakeConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._snowflakes = Array.from({ length: MAX_SNOWFLAKES }, (_, i) => {
|
||||
const duration = Math.random() * 10 + 10;
|
||||
const delay = -Math.random() * duration * 0.8;
|
||||
return {
|
||||
id: i,
|
||||
char: SNOWFLAKE_CHARS[Math.floor(Math.random() * SNOWFLAKE_CHARS.length)],
|
||||
size: `${Math.random() * 1.5 + 0.5}em`,
|
||||
maxOpacity: Math.random() * 0.5 + 0.5,
|
||||
fallDuration: `${duration}s`,
|
||||
fallDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 100}%`,
|
||||
endX: `${Math.random() * 100}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._snowflakes,
|
||||
(snowflake) => snowflake.id,
|
||||
(snowflake) => html`
|
||||
<advanced-camera-card-snowflake
|
||||
.char=${snowflake.char}
|
||||
.size=${snowflake.size}
|
||||
.maxOpacity=${snowflake.maxOpacity}
|
||||
.fallDuration=${snowflake.fallDuration}
|
||||
.fallDelay=${snowflake.fallDelay}
|
||||
.startX=${snowflake.startX}
|
||||
.endX=${snowflake.endX}
|
||||
></advanced-camera-card-snowflake>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-snow': AdvancedCameraCardEffectSnow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import snowflakeStyle from '../../scss/snowflake.scss';
|
||||
|
||||
@customElement('advanced-camera-card-snowflake')
|
||||
export class AdvancedCameraCardSnowflake extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '❄';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public fallDuration = '10s';
|
||||
|
||||
@property({ type: String })
|
||||
public fallDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public endX = '0%';
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--fall-duration', this.fallDuration);
|
||||
this.style.setProperty('--fall-delay', this.fallDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--end-x', this.endX);
|
||||
this.style.fontSize = this.size;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(snowflakeStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-snowflake': AdvancedCameraCardSnowflake;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,76 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import loadingStyle from '../scss/loading.scss';
|
||||
import { EffectName, EffectsControllerAPI } from '../types';
|
||||
import { getReleaseVersion } from '../utils/diagnostics';
|
||||
import './icon';
|
||||
|
||||
// Map of "MM-DD" to effect name for special dates.
|
||||
const DATE_EFFECTS: Record<string, EffectName> = {
|
||||
// New Year's Day
|
||||
'01-01': 'fireworks',
|
||||
|
||||
// Valentine's Day
|
||||
'02-14': 'hearts',
|
||||
|
||||
// St. Patrick's Day
|
||||
'03-17': 'shamrocks',
|
||||
|
||||
// Halloween
|
||||
'10-31': 'ghost',
|
||||
|
||||
// Christmas
|
||||
'12-25': 'snow',
|
||||
};
|
||||
|
||||
const getDateEffect = (): EffectName | null => {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return DATE_EFFECTS[`${month}-${day}`] ?? null;
|
||||
};
|
||||
|
||||
@customElement('advanced-camera-card-loading')
|
||||
export class AdvancedCameraCardLoading extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public effectsControllerAPI?: EffectsControllerAPI | null;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public loaded = false;
|
||||
|
||||
private _effectName: EffectName | null = null;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopEffect();
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const effect = getDateEffect();
|
||||
if (!effect) {
|
||||
this._stopEffect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.loaded) {
|
||||
this._startEffect(effect);
|
||||
} else {
|
||||
this._stopEffect();
|
||||
}
|
||||
}
|
||||
|
||||
private _startEffect(effect: EffectName): void {
|
||||
this.effectsControllerAPI?.startEffect(effect, { fadeIn: false });
|
||||
this._effectName = effect;
|
||||
}
|
||||
|
||||
private _stopEffect(): void {
|
||||
if (this._effectName) {
|
||||
this.effectsControllerAPI?.stopEffect(this._effectName);
|
||||
}
|
||||
this._effectName = null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'iris' }}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
CONF_MENU_BUTTONS_TIMELINE,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS,
|
||||
CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE,
|
||||
@@ -120,6 +121,7 @@ export const LOW_PERFORMANCE_PROFILE = {
|
||||
// Disable all optional performance related features.
|
||||
[CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR]: false,
|
||||
[CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR]: false,
|
||||
[CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS]: false,
|
||||
|
||||
// Load fewer media items by default.
|
||||
[CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE]: 10,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { EffectName } from '../../../../types';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './base';
|
||||
|
||||
const effectNameSchema = z.enum([
|
||||
'fireworks',
|
||||
'ghost',
|
||||
'hearts',
|
||||
'shamrocks',
|
||||
'snow',
|
||||
]) satisfies z.ZodType<EffectName>;
|
||||
|
||||
const effectActionSchema = z.enum(['start', 'stop', 'toggle']);
|
||||
export type EffectAction = z.infer<typeof effectActionSchema>;
|
||||
|
||||
export const effectActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend(
|
||||
{
|
||||
advanced_camera_card_action: z.literal('effect'),
|
||||
effect: effectNameSchema,
|
||||
effect_action: effectActionSchema.default('toggle'),
|
||||
},
|
||||
);
|
||||
|
||||
export type EffectActionConfig = z.infer<typeof effectActionConfigSchema>;
|
||||
@@ -3,6 +3,7 @@ import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
||||
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
||||
import { effectActionConfigSchema } from './custom/effect';
|
||||
import { generalActionConfigSchema } from './custom/general';
|
||||
import { internalCallbackActionConfigSchema } from './custom/internal';
|
||||
import { logActionConfigSchema } from './custom/log';
|
||||
@@ -41,6 +42,7 @@ export const statusBarActionConfigSchema: z.ZodSchema<
|
||||
|
||||
const advancedCameraCardCustomActionSchema = z.union([
|
||||
cameraSelectActionConfigSchema,
|
||||
effectActionConfigSchema,
|
||||
generalActionConfigSchema,
|
||||
internalCallbackActionConfigSchema,
|
||||
logActionConfigSchema,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../../const';
|
||||
export const performanceConfigDefault = {
|
||||
features: {
|
||||
animated_progress_indicator: true,
|
||||
card_loading_effects: true,
|
||||
card_loading_indicator: true,
|
||||
media_chunk_size: MEDIA_CHUNK_SIZE_DEFAULT,
|
||||
},
|
||||
@@ -20,6 +21,9 @@ export const performanceConfigSchema = z
|
||||
animated_progress_indicator: z
|
||||
.boolean()
|
||||
.default(performanceConfigDefault.features.animated_progress_indicator),
|
||||
card_loading_effects: z
|
||||
.boolean()
|
||||
.default(performanceConfigDefault.features.card_loading_effects),
|
||||
card_loading_indicator: z
|
||||
.boolean()
|
||||
.default(performanceConfigDefault.features.card_loading_indicator),
|
||||
|
||||
@@ -389,6 +389,7 @@ export const CONF_OVERRIDES = 'overrides' as const;
|
||||
|
||||
const CONF_PERFORMANCE = 'performance' as const;
|
||||
export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`;
|
||||
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS = `${CONF_PERFORMANCE}.features.card_loading_effects`;
|
||||
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR = `${CONF_PERFORMANCE}.features.card_loading_indicator`;
|
||||
export const CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`;
|
||||
export const CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS = `${CONF_PERFORMANCE}.features.max_simultaneous_engine_requests`;
|
||||
|
||||
@@ -191,6 +191,7 @@ import {
|
||||
CONF_MENU_POSITION,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS,
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR,
|
||||
CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS,
|
||||
CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE,
|
||||
@@ -3214,6 +3215,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR,
|
||||
this._defaults.performance.features.card_loading_indicator,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS,
|
||||
this._defaults.performance.features.card_loading_effects,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
this._defaults.performance.features.animated_progress_indicator,
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicador animat del progrés",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Opcions de característiques",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Animated Progress Indicator",
|
||||
"card_loading_effects": "Card Loading Effects",
|
||||
"card_loading_indicator": "Card Loading Indicator",
|
||||
"editor_label": "Feature Options",
|
||||
"max_simultaneous_engine_requests": "Max simultaneous camera engine requests",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicateur de progression animé",
|
||||
"card_loading_effects": "Effets au chargement",
|
||||
"card_loading_indicator": "Indicateur de chargement de la carte",
|
||||
"editor_label": "Options de fonctionnalités",
|
||||
"max_simultaneous_engine_requests": "Nombre maximal de requêtes simultanées au moteur de caméra",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicatore di avanzamento animato",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Opzioni funzionalità",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Indicador de Carregamento Animado",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Opções de recursos",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"performance": {
|
||||
"features": {
|
||||
"animated_progress_indicator": "Animação na barra de progresso",
|
||||
"card_loading_effects": "",
|
||||
"card_loading_indicator": "",
|
||||
"editor_label": "Editor de etiquetas",
|
||||
"max_simultaneous_engine_requests": "",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 1.5s ease-in;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@import './z-index.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
|
||||
pointer-events: none;
|
||||
z-index: #{$z-index-effect};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
left: var(--pos-x, 50%);
|
||||
top: var(--pos-y, 50%);
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0;
|
||||
animation: burst-appear 0.01s linear forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes burst-appear {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform, opacity;
|
||||
animation: explode var(--duration) ease-out forwards;
|
||||
animation-delay: var(--delay);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spark {
|
||||
font-size: var(--size, 4px);
|
||||
color: var(--color, #ffcc00);
|
||||
text-shadow:
|
||||
0 0 4px #fff,
|
||||
0 0 8px var(--color),
|
||||
0 0 16px var(--color),
|
||||
0 0 32px var(--color),
|
||||
0 0 48px var(--color);
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
|
||||
@keyframes explode {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(var(--end-x), var(--end-y)) scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 1.5s ease-in;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
font-size: 10em;
|
||||
color: #ffffff;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0.5;
|
||||
filter: drop-shadow(0 0 20px #ffffff) drop-shadow(0 0 40px #e0e0e0)
|
||||
drop-shadow(0 0 60px #c0c0c0);
|
||||
animation: ghost-float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ghost-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
filter: drop-shadow(0 0 20px #ffffff) drop-shadow(0 0 40px #e0e0e0)
|
||||
drop-shadow(0 0 60px #c0c0c0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
filter: drop-shadow(0 0 30px #ffffff) drop-shadow(0 0 60px #e0e0e0)
|
||||
drop-shadow(0 0 90px #c0c0c0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
top: var(--start-y);
|
||||
|
||||
font-size: var(--size, 1em);
|
||||
color: hsl(var(--hue, 340), var(--saturation, 80%), var(--lightness, 55%));
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: pulse var(--pulse-duration) ease-in-out infinite;
|
||||
animation-delay: var(--pulse-delay);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
85% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.heart {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
top: var(--start-y);
|
||||
|
||||
font-size: var(--size, 1em);
|
||||
color: hsl(var(--hue, 340), var(--saturation, 80%), var(--lightness, 55%));
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: pulse var(--pulse-duration) ease-in-out infinite;
|
||||
animation-delay: var(--pulse-delay);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
85% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
top: var(--start-y);
|
||||
|
||||
font-size: var(--size, 1em);
|
||||
|
||||
// Center the shamrock on its position.
|
||||
transform-origin: center center;
|
||||
translate: -50% -50%;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform, opacity;
|
||||
animation: shamrock-pulse var(--pulse-duration) ease-in-out infinite;
|
||||
animation-delay: var(--pulse-delay);
|
||||
}
|
||||
|
||||
@keyframes shamrock-pulse {
|
||||
0% {
|
||||
scale: 0;
|
||||
rotate: -20deg;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
50% {
|
||||
scale: 1;
|
||||
rotate: 10deg;
|
||||
}
|
||||
80% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
scale: 0;
|
||||
rotate: 25deg;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.snowflake {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
|
||||
color: white;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: fall var(--fall-duration) linear infinite;
|
||||
animation-delay: var(--fall-delay);
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
// Start above the card.
|
||||
top: -2em;
|
||||
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
25% {
|
||||
top: 25%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 8px));
|
||||
}
|
||||
50% {
|
||||
top: 50%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) - 10px));
|
||||
}
|
||||
75% {
|
||||
top: 75%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 20px));
|
||||
}
|
||||
90% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
// 100% ensures snowflakes fall through the entire card height
|
||||
top: 100%;
|
||||
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x)));
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
|
||||
left: var(--start-x);
|
||||
|
||||
color: white;
|
||||
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
will-change: transform;
|
||||
animation: fall var(--fall-duration) linear infinite;
|
||||
animation-delay: var(--fall-delay);
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
// Start above the card.
|
||||
top: -2em;
|
||||
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
25% {
|
||||
top: 25%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 8px));
|
||||
}
|
||||
50% {
|
||||
top: 50%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) - 10px));
|
||||
}
|
||||
75% {
|
||||
top: 75%;
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x) + 20px));
|
||||
}
|
||||
90% {
|
||||
opacity: var(--max-opacity, 1);
|
||||
}
|
||||
100% {
|
||||
// 100% ensures snowflakes fall through the entire card height
|
||||
top: 100%;
|
||||
|
||||
transform: translateX(calc(var(--end-x) - var(--start-x)));
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
// More-info dialog box has a z-index of 8, so everything meaningful needs to be
|
||||
// below that.
|
||||
|
||||
$z-index-effect: 7;
|
||||
$z-index-loading: 6;
|
||||
$z-index-submenu: 5;
|
||||
|
||||
|
||||
+10
-1
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
|
||||
import type { EffectOptions } from './components-lib/effects/types';
|
||||
import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
|
||||
|
||||
export type ClipsOrSnapshots = 'clips' | 'snapshots';
|
||||
export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all';
|
||||
@@ -177,3 +178,11 @@ export const signedPathSchema = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
export type SignedPath = z.infer<typeof signedPathSchema>;
|
||||
|
||||
export type EffectName = 'fireworks' | 'ghost' | 'hearts' | 'shamrocks' | 'snow';
|
||||
|
||||
export interface EffectsControllerAPI {
|
||||
startEffect(name: EffectName, options?: EffectOptions): Promise<void>;
|
||||
stopEffect(effect: EffectName): void;
|
||||
toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { CardActionsAPI } from '../card-controller/types.js';
|
||||
import { ZoomSettingsBase } from '../components-lib/zoom/types.js';
|
||||
import { CameraSelectActionConfig } from '../config/schema/actions/custom/camera-select.js';
|
||||
import { DisplayModeActionConfig } from '../config/schema/actions/custom/display-mode.js';
|
||||
import {
|
||||
EffectAction,
|
||||
EffectActionConfig,
|
||||
} from '../config/schema/actions/custom/effect.js';
|
||||
import {
|
||||
AdvancedCameraCardGeneralAction,
|
||||
GeneralActionConfig,
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
} from '../config/schema/actions/types.js';
|
||||
import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js';
|
||||
import { ServiceCallRequest } from '../ha/types.js';
|
||||
import { EffectName } from '../types.js';
|
||||
import { arrayify } from './basic.js';
|
||||
|
||||
export function createGeneralAction(
|
||||
@@ -239,6 +244,22 @@ export function createSelectOptionAction(
|
||||
});
|
||||
}
|
||||
|
||||
export function createEffectAction(
|
||||
effectName: EffectName,
|
||||
effectAction: EffectAction,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
},
|
||||
): EffectActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'effect',
|
||||
effect: effectName,
|
||||
effect_action: effectAction,
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
|
||||
@@ -327,3 +327,8 @@ export const generateFloatApproximatelyEqualsCustomizer = (
|
||||
export const convertHTTPAdressToWebsocket = (url: string): string => {
|
||||
return url.replace(/^http/i, 'ws');
|
||||
};
|
||||
|
||||
export const forceReflow = (element: HTMLElement): void => {
|
||||
// Force reflow by measuring the height.
|
||||
void element.offsetHeight;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user