refactor: Refactor media loading manager for improved robustness (#2464)
This commit is contained in:
committed by
dermotduffy
parent
47bcce93d3
commit
bc366626f1
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user