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
+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>`,
);
}