refactor: Refactor media loading manager for improved robustness (#2464)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 47bcce93d3
commit bc366626f1
51 changed files with 1897 additions and 1128 deletions
-106
View File
@@ -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<string, string>;
}
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<MediaLoadedInfo>): 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();
}
}
}
+11
View File
@@ -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<string, string>;
}
declare module 'view' {
interface ViewContext {
live?: LiveViewContext;
}
}
@@ -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
@@ -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<MediaLoadedInfo>): void => {
private _mediaLoadedHandler = (ev: CustomEvent<MediaLoadedInfoEventDetail>): 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();
};
+1 -48
View File
@@ -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<GridID, MediaGridChild>;
export interface MediaGridSelected {
@@ -51,7 +45,6 @@ export class MediaGridController {
private _host: HTMLElement;
private _selected: GridID | null;
private _mediaLoadedInfoMap: Map<GridID, MediaLoadedInfo> = 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<MediaLoadedInfo>): 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 {
@@ -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<string, MediaLoadedInfo>();
// 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<MediaLoadedInfoEventDetail>): 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();
}
}
@@ -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 `<video>`).
*
* Aggregator parents (e.g., `ha-camera-stream`) sit on the bubble path; they
* can `stopPropagation` on inner-leaf events and dispatch their own via their
* own source controller, so consumers above the boundary only see the
* aggregate.
*/
export class MediaLoadedInfoSourceController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _config: MediaLoadedInfoSourceConfig;
// AbortController for the active dispatch; aborting it fires the cleanup
// callbacks consumers registered against the event's `signal`. Non-null iff
// a `media:loaded` is currently in flight, in which case `_lastSet` is the
// info that was dispatched.
private _abort: AbortController | null = null;
// Survives disconnect so we can re-dispatch on reconnect. Only ever holds
// info validated by `set` — i.e., always has a targetID.
private _lastSet: TargetedMediaLoadedInfo | null = null;
constructor(
host: ReactiveControllerHost & HTMLElement,
config: MediaLoadedInfoSourceConfig,
) {
this._host = host;
this._config = config;
host.addController(this);
}
public hostConnected(): void {
// Two early-returns:
// - `!_lastSet`: nothing to replay — either the host has never seen a
// media load or the cache was discarded as stale on a prior reconnect
// (see below).
// - `_abort` non-null: a dispatch is already live, meaning we're already
// registered with consumers. Re-firing would orphan the prior
// `AbortController` (no one would ever abort it) and emit a duplicate
// event. Defensive against `hostConnected` firing without an intervening
// `hostDisconnected`.
if (!this._lastSet || this._abort) {
return;
}
// Revalidate against the current targetID — the host's property may have
// flipped while we were disconnected. Replaying the cached info under a
// stale targetID would misregister with the manager.
if (this._lastSet.targetID === this._config.getTargetID()) {
this._dispatchLoad(this._lastSet);
} else {
this._lastSet = null;
}
}
public hostDisconnected(): void {
this._unload();
}
public set(info: UntargetedMediaLoadedInfo): void {
const targetID = this._config.getTargetID();
if (!targetID) {
return;
}
// If the host's targetID changed since the prior dispatch, abort the
// prior dispatch's signal *before* registering the new one. Why:
// - When we dispatched the prior load, consumers (the manager, sinks,
// etc.) attached `onAbort` cleanup callbacks to that signal. The
// manager's callback is `_clearTarget(<old targetID>, owner)`.
// - If we skip the abort and proceed to `_dispatchLoad(new)`, we
// overwrite `_abort` with a fresh `AbortController`. The old
// controller becomes unreachable from us, but its signal is still
// held by consumers' listeners. We never call `.abort()` on it, so
// those cleanup callbacks never fire. The manager's
// `_active[<old targetID>]` entry zombies until the next time the
// host disconnects (which aborts only the *new* controller).
// - Aborting first triggers the old cleanup synchronously: the
// manager clears its old entry, sinks drop their cached info, and
// we then register the new entry cleanly.
//
// No current consumer rebinds targetID in-place (the substream layer
// keeps it stable above the playback chain), but the abstraction must
// remain safe in that general case.
if (this._lastSet && this._lastSet.targetID !== targetID) {
this._unload();
}
const validated: TargetedMediaLoadedInfo = { ...info, targetID };
if (isEquivalentInfo(this._lastSet, validated)) {
return;
}
this._lastSet = validated;
this._dispatchLoad(validated);
}
private _unload(): void {
this._abort?.abort();
this._abort = null;
}
private _dispatchLoad(info: TargetedMediaLoadedInfo): void {
this._abort = new AbortController();
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
info,
signal: this._abort.signal,
});
}
}