diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index 668140e9..46621fee 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -181,6 +181,12 @@ conditions: | `condition` | Must be `media_loaded`. | | `media_loaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. | +> [!NOTE] +> Toggling a substream on or off does not cause this condition to transition. +> Substream is treated as a playback-layer detail of the same logical camera, so +> the condition remains satisfied while any stream of the camera continues to +> render. + ## `microphone` Matches based on microphone state. diff --git a/src/card-controller/media-info-manager.ts b/src/card-controller/media-info-manager.ts index dc0aba66..77478c16 100644 --- a/src/card-controller/media-info-manager.ts +++ b/src/card-controller/media-info-manager.ts @@ -1,56 +1,124 @@ -import { MediaLoadedInfo } from '../types'; +import { + MediaLoadedInfo, + MediaLoadedInfoEventDetail, + MediaLoadedInfoOwner, +} from '../types'; +import { onAbort } from '../utils/abort-signal'; import { log } from '../utils/debug'; import { isValidMediaLoadedInfo } from '../utils/media-info'; import { CardMediaLoadedAPI } from './types'; +interface ActiveEntry { + info: MediaLoadedInfo; + owner: MediaLoadedInfoOwner; +} + export class MediaLoadedInfoManager { private _api: CardMediaLoadedAPI; - private _current: MediaLoadedInfo | null = null; - private _lastKnown: MediaLoadedInfo | null = null; + + // Active load per target: present iff a load is currently registered for that + // target. `owner` tags who set it so a late clear from a stale source (an + // element that's since been replaced) is a no-op. Keyed by targetID + // (see:`src/view/target-id.ts`). + private _active: Map = new Map(); + + // The last `info` ever seen per target, surviving transient unloads so + // consumers can retrieve the last-seen info after disconnect. Latch-only: + // never cleared by `clear` / `_clearTarget`, only by `initialize`. + private _lastKnown: Map = new Map(); + + // The currently "active" target — the one whose info drives condition state + // and card-level side effects. Driven by ViewManager on every view change. + private _selected: string | null = null; constructor(api: CardMediaLoadedAPI) { this._api = api; } public initialize(): void { - this.clear(); - } - - public set(mediaLoadedInfo: MediaLoadedInfo): void { - if (!isValidMediaLoadedInfo(mediaLoadedInfo)) { - return; - } - - log( - this._api.getConfigManager().getCardWideConfig(), - `Advanced Camera Card media load: `, - mediaLoadedInfo, - ); - - this._current = mediaLoadedInfo; - this._lastKnown = mediaLoadedInfo; - - this._api.getConditionStateManager().setState({ mediaLoadedInfo: mediaLoadedInfo }); - - // Fresh media information may change how the card is rendered. - this._api.getStyleManager().setExpandedMode(); - this._api.getCardElementManager().update(); - } - - public get(): MediaLoadedInfo | null { - return this._current; - } - - public getLastKnown(): MediaLoadedInfo | null { - return this._lastKnown; - } - - public clear(): void { - this._current = null; + this._active.clear(); + this._lastKnown.clear(); + this._selected = null; this._api.getConditionStateManager().setState({ mediaLoadedInfo: null }); } + public set(mediaLoadedInfo: MediaLoadedInfo, owner: MediaLoadedInfoOwner): void { + if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) { + return; + } + + const targetID = mediaLoadedInfo.targetID; + log( + this._api.getConfigManager().getCardWideConfig(), + `Advanced Camera Card media load [target_id=${targetID}]: `, + mediaLoadedInfo, + ); + + this._active.set(targetID, { info: mediaLoadedInfo, owner }); + this._lastKnown.set(targetID, mediaLoadedInfo); + + if (targetID === this._selected) { + this._emitChange(mediaLoadedInfo); + } + } + + public setSelected(targetID: string | null): void { + if (this._selected === targetID) { + return; + } + + this._selected = targetID; + this._emitChange(targetID ? this._active.get(targetID)?.info ?? null : null); + } + + public get(): MediaLoadedInfo | null { + return this._selected ? this._active.get(this._selected)?.info ?? null : null; + } + public has(): boolean { - return !!this._current; + return !!this.get(); + } + + public getLastKnown(): MediaLoadedInfo | null { + return this._selected ? this._lastKnown.get(this._selected) ?? null : null; + } + + public handleLoadEvent(ev: CustomEvent): void { + // path[0] = the source-controller's host that dispatched the load; we use + // it as the ownership token so a late clear from a disconnected element + // can't blow away an entry that's since been overwritten by another host. + const owner = ev.composedPath()[0]; + const targetID = ev.detail.info.targetID; + if (!(owner instanceof HTMLElement) || !targetID) { + return; + } + this.set(ev.detail.info, owner); + onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner)); + } + + public clear(): void { + const selectedHadInfo = !!this._selected && this._active.has(this._selected); + this._active.clear(); + if (selectedHadInfo) { + this._api.getConditionStateManager().setState({ mediaLoadedInfo: null }); + } + } + + private _clearTarget(targetID: string, owner: MediaLoadedInfoOwner): void { + if (this._active.get(targetID)?.owner !== owner) { + return; + } + + this._active.delete(targetID); + + if (targetID === this._selected) { + this._api.getConditionStateManager().setState({ mediaLoadedInfo: null }); + } + } + + private _emitChange(mediaLoadedInfo: MediaLoadedInfo | null): void { + this._api.getConditionStateManager().setState({ mediaLoadedInfo }); + this._api.getStyleManager().setExpandedMode(); + this._api.getCardElementManager().update(); } } diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index 2f40f59b..e306ae9f 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -344,9 +344,9 @@ export class ViewManager implements ViewManagerInterface { this._view = view; this._epoch = this._createEpoch(oldView); - if (this.hasMajorMediaChange(oldView)) { - this._api.getMediaLoadedInfoManager().clear(); - } + this._api + .getMediaLoadedInfoManager() + .setSelected(view ? getViewTargetID(view) : null); if (oldView?.view !== view?.view) { this._api.getCardElementManager().scrollReset(); diff --git a/src/card.ts b/src/card.ts index b0f6a399..ef5fe6dd 100644 --- a/src/card.ts +++ b/src/card.ts @@ -33,7 +33,7 @@ import { REPO_URL } from './const.js'; import { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; -import { MediaLoadedInfo } from './types.js'; +import { MediaLoadedInfoEventDetail } from './types.js'; import { hasAction } from './utils/action.js'; import { getReleaseVersion } from './utils/diagnostics'; @@ -375,17 +375,15 @@ class AdvancedCameraCard extends LitElement { hasDoubleClick: hasAction(actions.double_tap_action), })} style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}" - @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { - this._controller.getMediaLoadedInfoManager().set(ev.detail); - }} - @advanced-camera-card:media:unloaded=${() => - this._controller.getMediaLoadedInfoManager().clear()} @advanced-camera-card:issue:notify=${(ev: CustomEvent) => this._controller.getIssueManager().showNotification(ev.detail)} @advanced-camera-card:issue:trigger=${({ detail: { key, ...context }, }: CustomEvent) => this._controller.getIssueManager().trigger(key, context)} + @advanced-camera-card:media:loaded=${( + ev: CustomEvent, + ) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)} @advanced-camera-card:media:volumechange=${ () => this.requestUpdate() /* Refresh mute menu button */ } diff --git a/src/components-lib/live/live-controller.ts b/src/components-lib/live/live-controller.ts deleted file mode 100644 index 73c375fe..00000000 --- a/src/components-lib/live/live-controller.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { LitElement, ReactiveController } from 'lit'; -import { MediaLoadedInfo } from '../../types.js'; -import { - AdvancedCameraCardMediaLoadedEventTarget, - dispatchExistingMediaLoadedInfoAsEvent, -} from '../../utils/media-info.js'; - -interface LiveViewContext { - // A cameraID override (used for dependencies/substreams to force a different - // camera to be live rather than the camera selected in the view). - overrides?: Map; -} - -declare module 'view' { - interface ViewContext { - live?: LiveViewContext; - } -} - -interface LastMediaLoadedInfo { - mediaLoadedInfo: MediaLoadedInfo; - source: EventTarget; -} - -type LiveControllerHost = LitElement & AdvancedCameraCardMediaLoadedEventTarget; - -export class LiveController implements ReactiveController { - private _host: LiveControllerHost; - - // Whether or not the live view is currently in the background (i.e. preloaded - // but not visible). - private _inBackground = false; - - // Intersection handler is used to detect when the live view flips between - // foreground and background (in preload mode). - private _intersectionObserver: IntersectionObserver; - - // MediaLoadedInfo object and target from the underlying live media. In the - // case of pre-loading these may be propagated later (from the original - // source). - private _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null; - - constructor(host: LiveControllerHost) { - this._host = host; - - host.addController(this); - - this._intersectionObserver = new IntersectionObserver( - this._intersectionHandler.bind(this), - ); - } - - public hostConnected(): void { - this._intersectionObserver.observe(this._host); - - this._host.addEventListener( - 'advanced-camera-card:media:loaded', - this._handleMediaLoaded, - ); - } - - public hostDisconnected(): void { - this._intersectionObserver.disconnect(); - - this._host.removeEventListener( - 'advanced-camera-card:media:loaded', - this._handleMediaLoaded, - ); - } - - public isInBackground(): boolean { - return this._inBackground; - } - - private _handleMediaLoaded = (ev: CustomEvent): void => { - this._lastMediaLoadedInfo = { - source: ev.composedPath()[0], - mediaLoadedInfo: ev.detail, - }; - - if (this._inBackground) { - ev.stopPropagation(); - } - }; - - private _intersectionHandler(entries: IntersectionObserverEntry[]): void { - const wasInBackground = this._inBackground; - this._inBackground = !entries.some((entry) => entry.isIntersecting); - - if (!this._inBackground && this._lastMediaLoadedInfo) { - // If this isn't being rendered in the background, the last render did not - // generate a message and there's a saved MediaInfo, dispatch it upwards. - dispatchExistingMediaLoadedInfoAsEvent( - // Specifically dispatch the event "where it came from", as otherwise - // the intermediate layers (e.g. media-carousel which controls the title - // popups) will not re-receive the events. - this._lastMediaLoadedInfo.source, - this._lastMediaLoadedInfo.mediaLoadedInfo, - ); - } - - if (wasInBackground !== this._inBackground) { - this._host.requestUpdate(); - } - } -} diff --git a/src/components-lib/live/types.ts b/src/components-lib/live/types.ts new file mode 100644 index 00000000..e01c30a7 --- /dev/null +++ b/src/components-lib/live/types.ts @@ -0,0 +1,11 @@ +export interface LiveViewContext { + // A cameraID override (used for dependencies/substreams to force a different + // camera to be live rather than the camera selected in the view). + overrides?: Map; +} + +declare module 'view' { + interface ViewContext { + live?: LiveViewContext; + } +} diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts index f6b6fb4a..2adef37d 100644 --- a/src/components-lib/media-actions-controller.ts +++ b/src/components-lib/media-actions-controller.ts @@ -6,7 +6,6 @@ import { AutoUnmuteCondition, } from '../config/schema/common/media-actions.js'; import { MediaPlayerElement } from '../types.js'; -import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info.js'; import { Timer } from '../utils/timer.js'; export interface MediaActionsControllerOptions { @@ -21,7 +20,7 @@ export interface MediaActionsControllerOptions { microphoneMuteSeconds?: number; } -type RenderRoot = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget; +type RenderRoot = HTMLElement; /** * General note: Always unmute before playing, since Chrome may pause a piece of diff --git a/src/components-lib/media-dimensions-container-controller.ts b/src/components-lib/media-dimensions-container-controller.ts index 91628a78..7ccfa8a5 100644 --- a/src/components-lib/media-dimensions-container-controller.ts +++ b/src/components-lib/media-dimensions-container-controller.ts @@ -1,13 +1,12 @@ import { ReactiveController, ReactiveControllerHost } from 'lit'; import { debounce } from 'lodash-es'; import { CameraDimensionsConfig } from '../config/schema/cameras'; -import { MediaLoadedInfo } from '../types'; +import { MediaLoadedInfoEventDetail } from '../types'; import { aspectRatioToString, setOrRemoveAttribute, setOrRemoveStyleProperty, } from '../utils/basic'; -import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout'; const ROTATED_ATTRIBUTE = 'rotated'; @@ -33,9 +32,7 @@ export class MediaDimensionsContainerController implements ReactiveController { private _dimensionsConfig: CameraDimensionsConfig | null = null; - private _innerContainer: - | (HTMLElement & AdvancedCameraCardMediaLoadedEventTarget) - | null = null; + private _innerContainer: HTMLElement | null = null; private _outerContainer: HTMLElement | null = null; public resize = debounce(this._resize.bind(this), 100, { trailing: true }); @@ -81,19 +78,20 @@ export class MediaDimensionsContainerController implements ReactiveController { ); } - private _mediaLoadedHandler = (ev: CustomEvent): void => { + private _mediaLoadedHandler = (ev: CustomEvent): void => { + const info = ev.detail.info; // Only resize if the media dimensions have changed (otherwise the loading // image whilst waiting for the stream, will trigger aresize every second). if ( - this._mediaDimensions?.width === ev.detail.width && - this._mediaDimensions.height === ev.detail.height + this._mediaDimensions?.width === info.width && + this._mediaDimensions.height === info.height ) { return; } this._mediaDimensions = { - width: ev.detail.width, - height: ev.detail.height, + width: info.width, + height: info.height, }; this.resize(); }; diff --git a/src/components-lib/media-grid-controller.ts b/src/components-lib/media-grid-controller.ts index 0bb300f2..3f55a75b 100644 --- a/src/components-lib/media-grid-controller.ts +++ b/src/components-lib/media-grid-controller.ts @@ -1,7 +1,6 @@ import { isEqual, throttle } from 'lodash-es'; import Masonry from 'masonry-layout'; import { ViewDisplayConfig } from '../config/schema/common/display'; -import { MediaLoadedInfo } from '../types'; import { forceReflow, getChildrenFromElement, @@ -9,11 +8,6 @@ import { setOrRemoveStyleProperty, } from '../utils/basic'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event'; -import { - AdvancedCameraCardMediaLoadedEventTarget, - dispatchExistingMediaLoadedInfoAsEvent, - dispatchMediaUnloadedEvent, -} from '../utils/media-info'; // The default minimum cell width: if the columns are not specified this value // is used to compute the number of columns, always trying to keep each cell as @@ -25,7 +19,7 @@ const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0; const MEDIA_GRID_HORIZONTAL_GUTTER_WIDTH = 1; type GridID = string; -type MediaGridChild = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget; +type MediaGridChild = HTMLElement; type MediaGridContents = Map; export interface MediaGridSelected { @@ -51,7 +45,6 @@ export class MediaGridController { private _host: HTMLElement; private _selected: GridID | null; - private _mediaLoadedInfoMap: Map = new Map(); private _gridContents: MediaGridContents = new Map(); private _masonry: ExtendedMasonry | null = null; private _displayConfig: ViewDisplayConfig | null = null; @@ -111,7 +104,6 @@ export class MediaGridController { this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost); } - this._mediaLoadedInfoMap.clear(); this._masonry?.destroy?.(); this._masonry = null; @@ -176,11 +168,6 @@ export class MediaGridController { this._selected = id; fireAdvancedCameraCardEvent(this._host, 'media-grid:selected', { selected: id }); - const mediaLoadedInfo = this._mediaLoadedInfoMap.get(id); - if (mediaLoadedInfo) { - dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo); - } - this._sortItemsInGrid(); this._updateSelectedStylesOnElements(); @@ -189,7 +176,6 @@ export class MediaGridController { public unselectAll() { if (this._selected !== null) { - dispatchMediaUnloadedEvent(this._host); fireAdvancedCameraCardEvent(this._host, 'media-grid:unselected'); } this._selected = null; @@ -223,14 +209,6 @@ export class MediaGridController { private _setGridContents(gridContents: MediaGridContents): void { this._gridContents = gridContents; - // Remove media loaded info objects that belong to objects no longer in the - // grid. - for (const key of this._mediaLoadedInfoMap.keys()) { - if (!gridContents.has(key)) { - this._mediaLoadedInfoMap.delete(key); - } - } - if (this._selected !== null && !this._gridContents.has(this._selected)) { this.unselectAll(); } @@ -260,21 +238,6 @@ export class MediaGridController { this._setColumnSizeStyles(); } - private _handleMediaLoadedInfoEvent = (ev: CustomEvent): void => { - const eventPath = ev.composedPath(); - - for (const [id, element] of this._gridContents.entries()) { - /* istanbul ignore else: the else path cannot be reached -- @preserve */ - if (eventPath.includes(element)) { - this._mediaLoadedInfoMap.set(id, ev.detail); - if (id !== this._selected) { - ev.stopPropagation(); - } - break; - } - } - }; - private _hostResizeHandler(): void { const dimensions = this._host.getBoundingClientRect(); @@ -303,22 +266,12 @@ export class MediaGridController { child.addEventListener('click', this._handleSelectGridCellEvent, { capture: true, }); - - child.addEventListener( - 'advanced-camera-card:media:loaded', - this._handleMediaLoadedInfoEvent, - ); } private _removeChildEventListeners(child: MediaGridChild): void { child.removeEventListener('click', this._handleSelectGridCellEvent, { capture: true, }); - - child.removeEventListener( - 'advanced-camera-card:media:loaded', - this._handleMediaLoadedInfoEvent, - ); } private _createMasonry(): void { diff --git a/src/components-lib/media-loaded-info-sink-controller.ts b/src/components-lib/media-loaded-info-sink-controller.ts new file mode 100644 index 00000000..22c411ec --- /dev/null +++ b/src/components-lib/media-loaded-info-sink-controller.ts @@ -0,0 +1,129 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { MediaLoadedInfo, MediaLoadedInfoEventDetail } from '../types'; +import { onAbort } from '../utils/abort-signal'; + +interface MediaLoadedInfoSinkConfig { + // The currently active target the sink should expose via `get()` / `has()` + // and notify the callback for. Polled on every host update so the sink can + // detect selection changes (e.g. carousel slide change) without explicit + // notification. + getTargetID: () => string | null; + + // Fires when the active info changes — i.e. when the active target's entry + // transitions (load arrives, abort, or selection changes the active entry). + // Loads for other targets are cached but do not fire the callback. + callback?: (info: MediaLoadedInfo | null) => void; +} + +/** + * Sink-side ReactiveController for the media-loaded lifecycle. + * + * Listens for `advanced-camera-card:media:loaded` events bubbling through the + * host's subtree and caches them keyed by `MediaLoadedInfo.targetID`. The sink + * exposes only the entry for the currently active target (via `getTargetID`). + * + * This per-target shape is what makes the sink work inside carousels that + * render multiple slides concurrently: non-active slides may load and update + * the cache, but they don't become the carousel's active media. When the + * user selects a slide whose media has already loaded, the sink immediately + * exposes the cached entry. + * + * Lifecycle asymmetry — `callback` fires on: + * - the active target's load arrival, + * - the active target's source aborting (with `null`), and + * - selection changing to / from a target whose active info differs. + * + * It does NOT fire on the sink's own `hostDisconnected`: the host is detaching + * and won't render, so notifying consumers of a "transition to null" is moot. + * State is still cleared so a later reconnect doesn't observe stale info. + */ +export class MediaLoadedInfoSinkController implements ReactiveController { + private _host: ReactiveControllerHost & HTMLElement; + private _config: MediaLoadedInfoSinkConfig; + + // Per-target cache. Each entry holds the latest info dispatched under that + // targetID; aborts clear only the matching entry, scoped by reference so a + // stale abort can't blow away an entry that's since been overwritten. + private _byTarget = new Map(); + + // The targetID whose info we last surfaced — drives `hostUpdated` change + // detection. `_lastActiveInfo` records what the callback last saw, so we + // don't fire it for no-op selection changes (e.g. selection changes but + // both old and new are null/loaded with the same info reference). + private _lastActiveTargetID: string | null = null; + private _lastActiveInfo: MediaLoadedInfo | null = null; + + constructor( + host: ReactiveControllerHost & HTMLElement, + config: MediaLoadedInfoSinkConfig, + ) { + this._host = host; + this._config = config; + host.addController(this); + } + + public hostConnected(): void { + this._host.addEventListener('advanced-camera-card:media:loaded', this._handler); + } + + public hostUpdated(): void { + // Detect selection changes — `getTargetID` is owned by the host and may + // flip when its props change (carousel slide change, view change, etc.). + const newID = this._config.getTargetID(); + if (newID !== this._lastActiveTargetID) { + this._lastActiveTargetID = newID; + this._notifyIfActiveChanged(); + } + } + + public hostDisconnected(): void { + this._host.removeEventListener('advanced-camera-card:media:loaded', this._handler); + + // Clear without firing `callback`/`requestUpdate`: see class doc. + this._byTarget.clear(); + this._lastActiveTargetID = null; + this._lastActiveInfo = null; + } + + public get(): MediaLoadedInfo | null { + const id = this._config.getTargetID(); + return id ? this._byTarget.get(id) ?? null : null; + } + + public has(): boolean { + return !!this.get(); + } + + private _handler = (ev: CustomEvent): void => { + const id = ev.detail.info.targetID; + if (!id) { + return; + } + this._byTarget.set(id, ev.detail.info); + if (id === this._config.getTargetID()) { + this._notifyIfActiveChanged(); + } + + onAbort(ev.detail.signal, () => { + // Reference check: a newer load for the same target has overwritten + // this entry, so this stale abort is a no-op. + if (this._byTarget.get(id) !== ev.detail.info) { + return; + } + this._byTarget.delete(id); + if (id === this._config.getTargetID()) { + this._notifyIfActiveChanged(); + } + }); + }; + + private _notifyIfActiveChanged(): void { + const active = this.get(); + if (active === this._lastActiveInfo) { + return; + } + this._lastActiveInfo = active; + this._config.callback?.(active); + this._host.requestUpdate(); + } +} diff --git a/src/components-lib/media-loaded-info-source-controller.ts b/src/components-lib/media-loaded-info-source-controller.ts new file mode 100644 index 00000000..cf934a6b --- /dev/null +++ b/src/components-lib/media-loaded-info-source-controller.ts @@ -0,0 +1,144 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { isEqual, omit } from 'lodash-es'; +import { + MediaLoadedInfo, + MediaLoadedInfoEventDetail, + UntargetedMediaLoadedInfo, +} from '../types'; +import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event'; + +// Structural equality on `MediaLoadedInfo`, except `mediaPlayerController` is +// reference-compared. Implementations hold a `LitElement` host whose own +// properties include all `@property` bindings (`hass`, `cameraManager`, etc.) +// plus Lit's transient render bookkeeping; deep-walking those is expensive, +// reaches across unrelated app state, and produces false negatives from fields +// that flip between renders. Reference identity is the right granularity: same +// controller instance = same player. +const isEquivalentInfo = (a: MediaLoadedInfo | null, b: MediaLoadedInfo): boolean => + !!a && + a.mediaPlayerController === b.mediaPlayerController && + isEqual(omit(a, 'mediaPlayerController'), omit(b, 'mediaPlayerController')); + +interface MediaLoadedInfoSourceConfig { + getTargetID: () => string | null; +} + +type TargetedMediaLoadedInfo = MediaLoadedInfo & { targetID: string }; + +/** + * Source-side ReactiveController for the media-loaded lifecycle. + * + * On `set(info)` dispatches a bubbling `advanced-camera-card:media:loaded` + * event with `{ info, signal }`. Listeners along the bubble path register + * cleanup via the signal. The signal fires on host disconnect. + * + * Reconnect path: Lit may reuse the host across disconnect/reconnect. We + * re-dispatch from `_lastSet` on `hostConnected` so the registration survives + * without needing the underlying media to re-fire a load (e.g., HaHlsPlayer + * keeps the same `