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
+6
View File
@@ -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.
+106 -38
View File
@@ -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<string, ActiveEntry> = 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<string, MediaLoadedInfo> = 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<MediaLoadedInfoEventDetail>): 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();
}
}
+3 -3
View File
@@ -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();
+4 -6
View File
@@ -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<MediaLoadedInfo>) => {
this._controller.getMediaLoadedInfoManager().set(ev.detail);
}}
@advanced-camera-card:media:unloaded=${() =>
this._controller.getMediaLoadedInfoManager().clear()}
@advanced-camera-card:issue:notify=${(ev: CustomEvent<IssueKey>) =>
this._controller.getIssueManager().showNotification(ev.detail)}
@advanced-camera-card:issue:trigger=${({
detail: { key, ...context },
}: CustomEvent<IssueTriggerEventData>) =>
this._controller.getIssueManager().trigger(key, context)}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
@advanced-camera-card:media:volumechange=${
() => this.requestUpdate() /* Refresh mute menu button */
}
-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,
});
}
}
+13 -2
View File
@@ -2,6 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { ImageMediaPlayerController } from '../components-lib/media-player/image';
import imagePlayerStyle from '../scss/image-player.scss';
import {
@@ -10,7 +11,7 @@ import {
MediaPlayerElement,
MediaTechnology,
} from '../types';
import { dispatchMediaLoadedEvent } from '../utils/media-info';
import { createMediaLoadedInfo } from '../utils/media-info';
/**
* A simple media player to wrap a single static image.
@@ -20,6 +21,9 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
@property()
public url?: string;
@property()
public targetID?: string;
@property()
public technology?: MediaTechnology;
@@ -29,6 +33,10 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
() => this._refImage.value ?? null,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
@@ -38,12 +46,15 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
${ref(this._refImage)}
src="${ifDefined(this.url)}"
@load=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, {
const info = createMediaLoadedInfo(ev, {
...(this._mediaPlayerController && {
mediaPlayerController: this._mediaPlayerController,
}),
technology: [this.technology ?? ('jpg' as const)],
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
}}
/>`;
}
+20 -14
View File
@@ -10,10 +10,10 @@ import {
import { customElement, property, state } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { isEqual } from 'lodash-es';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
import { dataToContext } from '../components-lib/notification/data-to-context.js';
import { SignedURLController } from '../components-lib/signed-url-controller.js';
@@ -27,16 +27,14 @@ import { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
import { localize } from '../localize/localize.js';
import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss';
import { MediaLoadedInfo, MediaPlayer, MediaPlayerController } from '../types.js';
import { MediaPlayer, MediaPlayerController } from '../types.js';
import { contentsChanged } from '../utils/basic.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../utils/media-info.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js';
import { View } from '../view/view.js';
import { renderNotificationBlock } from './notification/block.js';
@@ -90,6 +88,9 @@ export class AdvancedCameraCardImageUpdatingPlayer
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public targetID?: string;
@property({ attribute: false, hasChanged: contentsChanged })
public proxyConfig?: EnabledProxyConfig;
@@ -143,14 +144,16 @@ export class AdvancedCameraCardImageUpdatingPlayer
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
private _mediaLoadedInfo: MediaLoadedInfo | null = null;
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
this,
() => this._refImage.value ?? null,
() => this._cachedValueController,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
@@ -452,11 +455,8 @@ export class AdvancedCameraCardImageUpdatingPlayer
supportsPause: !!this._getEffectiveRefreshSeconds(),
},
});
// Avoid the media being reported as repeatedly loading unless the
// media info changes.
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
this._mediaLoadedInfo = mediaLoadedInfo;
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
if (mediaLoadedInfo) {
this._mediaLoadedInfoSourceController.set(mediaLoadedInfo);
}
}}
@error=${() => {
@@ -469,10 +469,16 @@ export class AdvancedCameraCardImageUpdatingPlayer
} else if (mode === 'url') {
this._imageLoadError = true;
}
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
if (this.targetID) {
fireAdvancedCameraCardEvent<IssueTriggerEventData>(
this,
'issue:trigger',
{
key: 'media_load',
targetID: IMAGE_VIEW_TARGET_ID_SENTINEL,
});
targetID: this.targetID,
},
);
}
}}
/>
`
+1
View File
@@ -127,6 +127,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
.view=${view}
.imageConfig=${this.imageConfig}
.cameraConfig=${this.cameraConfig}
.targetID=${IMAGE_VIEW_TARGET_ID_SENTINEL}
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ??
undefined}
>
+23 -36
View File
@@ -6,8 +6,7 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { customElement, property } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
@@ -16,6 +15,7 @@ import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
import { PTZDragController } from '../../components-lib/ptz/drag-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
@@ -30,12 +30,10 @@ import { HomeAssistant } from '../../ha/types.js';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import { getStreamCameraID } from '../../utils/substream.js';
import { getTextDirection } from '../../utils/text-direction.js';
import { View } from '../../view/view.js';
import '../carousel';
import { EmblaCarouselPlugins } from '../carousel.js';
import '../next-prev-control.js';
import '../ptz.js';
import './provider.js';
@@ -82,8 +80,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
private _ptzDragController = new PTZDragController(this);
@state()
private _mediaHasLoaded = false;
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
getTargetID: () =>
this.viewFilterCameraID ??
this.viewManagerEpoch?.manager.getView()?.camera ??
null,
callback: () => this._mediaHeightController.recalculate(),
});
public connectedCallback(): void {
super.connectedCallback();
@@ -172,23 +175,18 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
}
}
private _getPlugins(): EmblaCarouselPlugins {
return [AutoMediaLoadedInfo()];
}
private _getSlides(): TemplateResult[] {
if (!this.cameraManager) {
return [];
}
const view = this.viewManagerEpoch?.manager.getView();
const cameraIDs = this.viewFilterCameraID
? new Set([this.viewFilterCameraID])
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const slides: TemplateResult[] = [];
for (const cameraID of cameraIDs ?? []) {
const slide = this._renderLive(this._getSubstreamCameraID(cameraID, view));
const slide = this._renderLive(cameraID);
if (slide) {
slides.push(slide);
}
@@ -214,13 +212,20 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
}
private _renderLive(cameraID: string): TemplateResult | void {
const camera = this.cameraManager?.getStore().getCamera(cameraID);
if (!this.liveConfig || !this.hass || !this.cameraManager || !camera) {
const view = this.viewManagerEpoch?.manager.getView();
// Resolve substream INTERNALLY: the substream is a playback-layer concern
// (which `Camera` to actually stream), invisible above the provider. The
// base `cameraID` flows up as the targetID; the substream `Camera` flows
// down for playback.
const resolvedCamera = this.cameraManager
?.getStore()
.getCamera(this._getSubstreamCameraID(cameraID, view));
if (!this.liveConfig || !this.hass || !this.cameraManager || !resolvedCamera) {
return;
}
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
const view = this.viewManagerEpoch?.manager.getView();
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
return html`
@@ -231,11 +236,8 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.microphoneState=${view?.camera === cameraID
? this.microphoneState
: undefined}
.camera=${camera}
.cameraEndpoints=${guard(
[this.cameraManager, cameraID],
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)}
.camera=${resolvedCamera}
.targetID=${cameraID}
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${this.liveConfig}
.hass=${this.hass}
@@ -348,7 +350,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID);
const forcePTZVisibility =
!this._mediaHasLoaded ||
!this._mediaLoadedInfoSinkController.has() ||
(!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) ||
view.context?.ptzControls?.enabled === false
? false
@@ -357,30 +359,15 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const dragEnabled =
hasMultipleCameras && this.liveConfig?.draggable && !gesturesPTZActive;
// Notes on the below:
// - guard() is used to avoid reseting the carousel unless the
// options/plugins actually change.
return html`
<advanced-camera-card-carousel
${ref(this._refCarousel)}
.loop=${hasMultipleCameras}
.dragEnabled=${dragEnabled}
.plugins=${guard(
[this.cameraManager, this.liveConfig],
this._getPlugins.bind(this),
)}
.selected=${this._getSelectedCameraIndex()}
.wheelScrolling=${this.liveConfig?.controls.wheel}
transitionEffect=${this._getTransitionEffect()}
@advanced-camera-card:carousel:select=${this._setViewHandler.bind(this)}
@advanced-camera-card:media:loaded=${() => {
this._mediaHasLoaded = true;
this._mediaHeightController.recalculate();
}}
@advanced-camera-card:media:unloaded=${() => {
this._mediaHasLoaded = false;
}}
>
${this._renderNextPrevious('left', neighbors)}
<!-- -->
+1 -4
View File
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LiveController } from '../../components-lib/live/live-controller.js';
import '../../components-lib/live/types.js';
import { LiveConfig } from '../../config/schema/live.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { HomeAssistant } from '../../ha/types.js';
@@ -34,8 +34,6 @@ export class AdvancedCameraCardLive extends LitElement {
@property({ attribute: false, hasChanged: contentsChanged })
public triggeredCameraIDs?: Set<string>;
private _controller = new LiveController(this);
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraManager) {
return;
@@ -46,7 +44,6 @@ export class AdvancedCameraCardLive extends LitElement {
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.liveConfig=${this.liveConfig}
.inBackground=${this._controller.isInBackground()}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneState=${this.microphoneState}
+29 -51
View File
@@ -11,25 +11,19 @@ import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { Camera } from '../../camera-manager/camera.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
import { LiveConfig } from '../../config/schema/live.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import liveProviderStyle from '../../scss/live-provider.scss';
import {
MediaLoadedInfo,
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
} from '../../types.js';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event.js';
import { getResolvedLiveProvider } from '../../utils/live-provider.js';
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import '../icon.js';
import { renderNotificationBlockFromText } from '../notification/block.js';
import './../media-dimensions-container';
@@ -42,8 +36,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
public targetID?: string;
@property({ attribute: false })
public liveConfig?: LiveConfig;
@@ -64,8 +59,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public zoom = true;
@state()
private _isVideoMediaLoaded = false;
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
getTargetID: () => this.targetID ?? null,
});
@state()
private _zoomed = false;
@@ -95,16 +91,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
// the background. These calls fail without waiting for loading here.
private _importPromises: Promise<unknown>[] = [];
constructor() {
super();
this._lazyLoadController.addListener((loaded: boolean) => {
if (!loaded) {
this._isVideoMediaLoaded = false;
dispatchMediaUnloadedEvent(this);
}
});
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
await this.updateComplete;
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
@@ -117,7 +103,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
*/
private _shouldShowImageDuringLoading(): boolean {
return (
!this._isVideoMediaLoaded &&
!this._mediaLoadedInfoSinkController.has() &&
!!this.camera?.getConfig()?.camera_entity &&
!!this.hass &&
!!this.liveConfig?.show_image_during_load &&
@@ -126,25 +112,18 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
public disconnectedCallback(): void {
this._isVideoMediaLoaded = false;
this._entityHasBeenAvailable = false;
super.disconnectedCallback();
}
private _videoMediaShowHandler(): void {
this._isVideoMediaLoaded = true;
}
private _providerErrorHandler(ev: Event): void {
ev.stopPropagation();
this._hasProviderError = true;
// this.camera is already substream-aware (resolved by the carousel layer).
const targetID = this.camera?.getID();
if (targetID) {
if (this.targetID) {
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
key: 'media_load' as const,
targetID,
targetID: this.targetID,
});
}
}
@@ -170,7 +149,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
if (changedProps.has('camera')) {
this._isVideoMediaLoaded = false;
this._hasProviderError = false;
this._entityHasBeenAvailable = false;
@@ -212,13 +190,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
const config = this.camera?.getConfig();
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${config?.dimensions}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (ev.detail.placeholder) {
ev.stopPropagation();
} else {
this._videoMediaShowHandler();
}
}}
>
${template}
</advanced-camera-card-media-dimensions-container>`;
@@ -289,7 +260,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
cameraConfig.always_error_if_entity_unavailable
) {
dispatchLiveErrorEvent(this);
dispatchMediaUnloadedEvent(this);
return renderNotificationBlockFromText(
`${localize('error.live_camera_unavailable')}${
this.label ? `: ${this.label}` : ''
@@ -303,7 +273,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
const showImageDuringLoading = this._shouldShowImageDuringLoading();
const showLoadingIcon = !this._isVideoMediaLoaded;
const showLoadingIcon = !this._mediaLoadedInfoSinkController.has();
const classes = {
hidden: showImageDuringLoading,
@@ -314,8 +284,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
? html` <advanced-camera-card-live-image
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.proxyConfig=${this.camera.getLiveProxyConfig()}
.camera=${this.camera}
.targetID=${this.targetID}
class=${classMap({
...classes,
// The image provider is providing the temporary loading image,
@@ -324,8 +294,15 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
})}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
ev.detail.placeholder = provider !== 'image';
@advanced-camera-card:media:loaded=${(ev: Event) => {
// When the image is rendered as a placeholder behind another
// provider, suppress its load event so it doesn't reach the
// card-root listener and clobber the real provider's
// registration. The real provider's load event will arrive
// afterwards.
if (provider !== 'image') {
ev.stopPropagation();
}
}}
>
</advanced-camera-card-live-image>`
@@ -335,7 +312,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)}
class=${classMap(classes)}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.camera=${this.camera}
.targetID=${this.targetID}
?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
@@ -347,7 +325,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
class=${classMap(classes)}
.hass=${this.hass}
.camera=${this.camera}
.cameraEndpoints=${this.cameraEndpoints}
.targetID=${this.targetID}
.microphoneState=${this.microphoneState}
.microphoneConfig=${this.liveConfig.microphone}
?controls=${this._getEffectiveBuiltinControls()}
@@ -360,8 +338,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)}
class=${classMap(classes)}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.camera=${this.camera}
.targetID=${this.targetID}
.cardWideConfig=${this.cardWideConfig}
?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${(ev: Event) =>
@@ -373,8 +351,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)}
class=${classMap(classes)}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.camera=${this.camera}
.targetID=${this.targetID}
.cardWideConfig=${this.cardWideConfig}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
+10 -9
View File
@@ -8,7 +8,6 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Camera } from '../../../../camera-manager/camera.js';
import { CameraEndpoints } from '../../../../camera-manager/types.js';
import { MicrophoneState } from '../../../../card-controller/types.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
@@ -31,8 +30,9 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
@property({ attribute: false })
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
public targetID?: string;
@property({ attribute: false })
public microphoneState?: MicrophoneState;
@@ -55,7 +55,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
private _signedURLController = new SignedURLController(
this,
() => {
const endpoint = this.cameraEndpoints?.go2rtc;
const endpoint = this.camera?.getEndpoints()?.go2rtc;
if (!this.hass || !endpoint) {
return {};
}
@@ -93,6 +93,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
}
this._player = new VideoRTC();
this._player.targetID = this.targetID ?? null;
this._player.mediaPlayerController = this._mediaPlayerController;
this._player.microphoneStream = this.microphoneState?.stream ?? null;
this._player.src = src;
@@ -108,17 +109,17 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraEndpoints')) {
if (changedProps.has('camera')) {
// Clear old player; the new one is created by the
// SignedURLController's valueChangeCallback once the URL resolves.
this._player = undefined;
}
// Only treat a missing go2rtc endpoint as an error after cameraEndpoints
// has been explicitly set (not undefined / still loading).
// Only treat a missing go2rtc endpoint as an error after the camera's
// endpoints have been explicitly set (not undefined / still loading).
const endpoints = this.camera?.getEndpoints();
const hasError =
!!this._signedURLController.getError() ||
(!!this.cameraEndpoints && !this.cameraEndpoints.go2rtc);
!!this._signedURLController.getError() || (!!endpoints && !endpoints.go2rtc);
if (hasError && !this._hasLiveError) {
dispatchLiveErrorEvent(this);
}
@@ -149,7 +150,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
{ context: this.camera?.getConfig() },
);
}
if (!this.cameraEndpoints?.go2rtc) {
if (!this.camera?.getEndpoints()?.go2rtc) {
return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), {
context: this.camera?.getConfig(),
});
+1
View File
@@ -33,6 +33,7 @@ export class VideoRTC extends HTMLElement {
// Custom methods/members.
mediaPlayerController: MediaPlayerController | null;
microphoneStream: MediaStream | null;
targetID: string | null;
reconnect();
setControls(controls: boolean): void;
}
@@ -9,7 +9,7 @@ import {
setControlsOnVideo,
} from '../../../../utils/controls.js';
import {
dispatchMediaLoadedEvent,
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
@@ -171,6 +171,20 @@ export class VideoRTC extends HTMLElement {
*/
this.mediaPlayerController = null;
/**
* Identifies which camera the loaded media belongs to.
* @type {string | null}
*/
this.targetID = null;
/**
* Cancellation token for the current load registration. Aborted on
* disconnect to fire all cleanup listeners that recipients attached to
* its signal.
* @type {AbortController | null}
*/
this._abortController = null;
/**
* Whether to show or hide video controls for videos created *in future*.
* @type {boolean}}
@@ -188,7 +202,7 @@ export class VideoRTC extends HTMLElement {
* Dispatch a media loaded event with current capabilities.
*/
_dispatchMediaLoadedEvent() {
dispatchMediaLoadedEvent(this, this.video, {
const info = createMediaLoadedInfo(this.video, {
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
@@ -199,6 +213,27 @@ export class VideoRTC extends HTMLElement {
},
technology: getTechnologyForVideoRTC(this),
});
if (info) {
this._dispatchMediaLoadedInfo(info);
}
}
_dispatchMediaLoadedInfo(info) {
if (!this.targetID) {
return;
}
this._abortController = new AbortController();
this.dispatchEvent(
new CustomEvent('advanced-camera-card:media:loaded', {
bubbles: true,
composed: true,
cancelable: false,
detail: {
info: { ...info, targetID: this.targetID },
signal: this._abortController.signal,
},
}),
);
}
/**
@@ -298,6 +333,12 @@ export class VideoRTC extends HTMLElement {
* document's DOM.
*/
disconnectedCallback() {
// Synchronous manager-side cleanup by aborting the load's signal. The
// signal's abort listeners — registered by the card-root listener and
// any sinks in the bubble path — fire even though `parentNode` is
// already null, because abort is plain JS, not DOM-event-bound.
this._abortController?.abort();
this._abortController = null;
if (this.background || this.disconnectTID) return;
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
@@ -767,12 +808,15 @@ export class VideoRTC extends HTMLElement {
if (!receivedFirstFrame) {
receivedFirstFrame = true;
dispatchMediaLoadedEvent(this, this.video, {
const info = createMediaLoadedInfo(this.video, {
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
technology: ['mjpeg'],
});
if (info) {
this._dispatchMediaLoadedInfo(info);
}
}
};
@@ -814,12 +858,15 @@ export class VideoRTC extends HTMLElement {
canvas.height = video2.videoHeight;
context = canvas.getContext('2d');
dispatchMediaLoadedEvent(this, video2, {
const info = createMediaLoadedInfo(video2, {
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
technology: ['mp4'],
});
if (info) {
this._dispatchMediaLoadedInfo(info);
}
}
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
+9 -5
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/schema/cameras';
import { Camera } from '../../../camera-manager/camera.js';
import { HomeAssistant } from '../../../ha/types';
import '../../../patches/ha-camera-stream';
import '../../../patches/ha-hls-player.js';
@@ -19,7 +19,11 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public targetID?: string;
@property({ attribute: true, type: Boolean })
public controls = false;
@@ -36,14 +40,14 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer
return;
}
const cameraEntity = this.camera?.getConfig()?.camera_entity;
return html` <advanced-camera-card-ha-camera-stream
${ref(this._playerRef)}
.hass=${this.hass}
.stateObj=${this.cameraConfig?.camera_entity
? this.hass.states[this.cameraConfig.camera_entity]
: undefined}
.stateObj=${cameraEntity ? this.hass.states[cameraEntity] : undefined}
.controls=${this.controls}
.muted=${true}
.targetID=${this.targetID}
>
</advanced-camera-card-ha-camera-stream>`;
}
+10 -8
View File
@@ -1,8 +1,7 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/schema/cameras';
import { EnabledProxyConfig } from '../../../config/schema/common/proxy';
import { Camera } from '../../../camera-manager/camera.js';
import { HomeAssistant } from '../../../ha/types';
import basicBlockStyle from '../../../scss/basic-block.scss';
import {
@@ -18,10 +17,11 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public proxyConfig?: EnabledProxyConfig;
public targetID?: string;
private _refImage: Ref<MediaPlayerElement> = createRef();
@@ -31,7 +31,8 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
}
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) {
const cameraConfig = this.camera?.getConfig();
if (!this.hass || !cameraConfig) {
return;
}
@@ -39,9 +40,10 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
<advanced-camera-card-image-updating-player
${ref(this._refImage)}
.hass=${this.hass}
.imageConfig=${this.cameraConfig.image}
.cameraConfig=${this.cameraConfig}
.proxyConfig=${this.proxyConfig}
.imageConfig=${cameraConfig.image}
.cameraConfig=${cameraConfig}
.targetID=${this.targetID}
.proxyConfig=${this.camera?.getLiveProxyConfig()}
>
</advanced-camera-card-image-updating-player>
`;
+23 -17
View File
@@ -9,12 +9,12 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { CameraEndpoints } from '../../../camera-manager/types.js';
import { Camera } from '../../../camera-manager/camera.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { CameraConfig } from '../../../config/schema/cameras.js';
import { CardWideConfig } from '../../../config/schema/types.js';
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
import { HomeAssistant } from '../../../ha/types.js';
@@ -23,7 +23,7 @@ import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
import { MediaPlayer, MediaPlayerController } from '../../../types.js';
import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
import {
dispatchMediaLoadedEvent,
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../../../utils/media-info.js';
@@ -44,10 +44,11 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
private hass?: HomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
public targetID?: string;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -65,14 +66,16 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
() => this._jsmpegCanvasElement ?? null,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
) {
if (changedProperties.has('camera')) {
this._notification = null;
}
}
@@ -99,7 +102,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
preserveDrawingBuffer: true,
// Override with user-specified options.
...this.cameraConfig?.jsmpeg?.options,
...this.camera?.getConfig()?.jsmpeg?.options,
// Don't allow the player to internally reconnect, as it may re-use a
// URL with a (newly) invalid signature, e.g. during a Home Assistant
@@ -120,17 +123,20 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
);
});
// The media loaded event must be dispatched after the player is assigned to
// `this._jsmpegVideoPlayer`, since the load call may (will!) result in
// calls back to the player to check for pause status for menu buttons.
// The media-loaded info must be reported after the player is assigned to
// `this._jsmpegVideoPlayer`, since the registration may result in calls
// back to the player to check for pause status for menu buttons.
if (this._jsmpegCanvasElement) {
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, {
const info = createMediaLoadedInfo(this._jsmpegCanvasElement, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
},
technology: ['jsmpeg'],
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
}
}
@@ -175,11 +181,11 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media';
const endpoint = this.cameraEndpoints?.jsmpeg;
const endpoint = this.camera?.getEndpoints()?.jsmpeg;
if (!endpoint) {
this._notification = createNotificationFromText(
localize('error.live_camera_no_endpoint'),
{ context: this.cameraConfig },
{ context: this.camera?.getConfig() },
);
dispatchLiveErrorEvent(this);
return;
@@ -199,7 +205,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
if (!address) {
this._notification = createNotificationFromText(localize('error.failed_sign'), {
context: this.cameraConfig,
context: this.camera?.getConfig(),
});
dispatchLiveErrorEvent(this);
return;
@@ -224,7 +230,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
if (!this._notification) {
this._notification = createNotificationFromText(
localize('error.jsmpeg_no_player'),
{ context: this.cameraConfig },
{ context: this.camera?.getConfig() },
);
dispatchLiveErrorEvent(this);
}
+21 -13
View File
@@ -8,13 +8,13 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { CameraEndpoints } from '../../../camera-manager/types.js';
import { Camera } from '../../../camera-manager/camera.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { CameraConfig } from '../../../config/schema/cameras.js';
import { CardWideConfig } from '../../../config/schema/types.js';
import { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js';
@@ -32,7 +32,7 @@ import {
} from '../../../utils/controls.js';
import { getContextFromError } from '../../../utils/error-context.js';
import {
dispatchMediaLoadedEvent,
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
@@ -49,10 +49,11 @@ import { VideoRTC } from './go2rtc/video-rtc.js';
@customElement('advanced-camera-card-live-webrtc-card')
export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements MediaPlayer {
@property({ attribute: false })
public cameraConfig?: CameraConfig;
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
public targetID?: string;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -73,6 +74,10 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
() => this.controls,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
@@ -95,9 +100,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
) {
if (changedProperties.has('camera')) {
this._notification = null;
}
}
@@ -120,7 +123,8 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
*/
private _createWebRTC(): HTMLElement | null {
const webrtcElement = this._webrtcTask.value;
if (webrtcElement && this.hass && this.cameraConfig) {
const cameraConfig = this.camera?.getConfig();
if (webrtcElement && this.hass && cameraConfig) {
const webrtc = new webrtcElement() as HTMLElement & {
hass: HomeAssistant;
setConfig: (config: Record<string, unknown>) => void;
@@ -137,10 +141,11 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1654
muted: true,
...this.cameraConfig.webrtc_card,
...cameraConfig.webrtc_card,
};
if (!config.url && !config.entity && this.cameraEndpoints?.webrtcCard) {
config.entity = this.cameraEndpoints.webrtcCard.endpoint;
const webrtcCardEndpoint = this.camera?.getEndpoints()?.webrtcCard;
if (!config.url && !config.entity && webrtcCardEndpoint) {
config.entity = webrtcCardEndpoint.endpoint;
}
webrtc.setConfig(config);
webrtc.hass = this.hass;
@@ -204,7 +209,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
if (this.controls) {
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}
dispatchMediaLoadedEvent(this, video, {
const info = createMediaLoadedInfo(video, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
@@ -214,6 +219,9 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
technology: getTechnologyForVideoRTC(this._videoRTC),
}),
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
};
video.onplay = () => dispatchMediaPlayEvent(this);
video.onpause = () => dispatchMediaPauseEvent(this);
+13 -2
View File
@@ -2,6 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../components-lib/media-player/video';
import videoPlayerStyle from '../scss/video-player.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types';
@@ -11,7 +12,7 @@ import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/controls';
import {
dispatchMediaLoadedEvent,
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
@@ -22,6 +23,9 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
@property()
public url?: string;
@property()
public targetID?: string;
@property({ type: Boolean })
public controls = false;
@@ -32,6 +36,10 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
() => this.controls,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
@@ -54,7 +62,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
}
}}
@loadeddata="${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, {
const info = createMediaLoadedInfo(ev, {
...(this._mediaPlayerController && {
mediaPlayerController: this._mediaPlayerController,
}),
@@ -64,6 +72,9 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
},
technology: ['mp4'],
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
}}"
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)}
+18 -27
View File
@@ -15,6 +15,7 @@ import { RemoveContextPropertyViewModifier } from '../../card-controller/view/mo
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
import { TransitionEffect } from '../../config/schema/common/transition-effect.js';
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
@@ -23,16 +24,13 @@ import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
import viewerCarouselStyle from '../../scss/viewer-carousel.scss';
import { MediaLoadedInfo, MediaPlayerController } from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import { getTextDirection } from '../../utils/text-direction.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewMedia } from '../../view/item.js';
import '../carousel';
import type { EmblaCarouselPlugins } from '../carousel.js';
import '../next-prev-control.js';
import { renderNoMedia } from '../notification/no-media.js';
import '../ptz.js';
@@ -87,9 +85,17 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
private _media: ViewMedia[] | null = null;
private _mediaActionsController = new MediaActionsController();
private _mediaHeightController = new MediaHeightController(this, '.embla__slide');
private _loadedMediaPlayerController: MediaPlayerController | null = null;
private _refCarousel: Ref<HTMLElement> = createRef();
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
getTargetID: () =>
(this._selected !== null && this._media?.[this._selected]?.getID()) || null,
callback: () => {
this._mediaHeightController.recalculate();
this._seekHandler();
},
});
public connectedCallback(): void {
super.connectedCallback();
@@ -116,14 +122,6 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
);
}
/**
* Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes.
*/
private _getPlugins(): EmblaCarouselPlugins {
return [AutoMediaLoadedInfo()];
}
/**
* Get the previous and next true media items from the current view.
* @returns A BrowseMediaNeighbors with indices and objects of true media
@@ -335,21 +333,12 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
<advanced-camera-card-carousel
${ref(this._refCarousel)}
.dragEnabled=${this.viewerConfig?.draggable ?? true}
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
.selected=${this._selected}
.wheelScrolling=${this.viewerConfig?.controls.wheel}
transitionEffect=${this._getTransitionEffect()}
@advanced-camera-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
this._setViewSelectedIndex(ev.detail.index);
}}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._loadedMediaPlayerController = ev.detail.mediaPlayerController ?? null;
this._mediaHeightController.recalculate();
this._seekHandler();
}}
@advanced-camera-card:media:unloaded=${() => {
this._loadedMediaPlayerController = null;
}}
>
${this.showControls ? this._renderNextPrevious('left', neighbors) : ''}
${guard([this._media, view], () => this._getSlides())}
@@ -420,10 +409,12 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
* Fire a media show event when a slide is selected.
*/
private async _seekHandler(): Promise<void> {
const mediaPlayerController =
this._mediaLoadedInfoSinkController.get()?.mediaPlayerController ?? null;
if (
!this.hass ||
!this._media ||
!this._loadedMediaPlayerController ||
!mediaPlayerController ||
this._selected === null
) {
return;
@@ -442,17 +433,17 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
const seekTimeInMedia = selectedMedia.includesTime(seek);
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
if (!seekTimeInMedia && !this._loadedMediaPlayerController.isPaused()) {
this._loadedMediaPlayerController.pause();
} else if (seekTimeInMedia && this._loadedMediaPlayerController.isPaused()) {
this._loadedMediaPlayerController.play();
if (!seekTimeInMedia && !mediaPlayerController.isPaused()) {
mediaPlayerController.pause();
} else if (seekTimeInMedia && mediaPlayerController.isPaused()) {
mediaPlayerController.play();
}
const seekTime =
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
if (seekTime !== null) {
this._loadedMediaPlayerController.seek(seekTime);
mediaPlayerController.seek(seekTime);
}
}
+4
View File
@@ -251,6 +251,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
// Note: crossorigin="anonymous" is required on <video> below in order to
// allow screenshot of motionEye videos which currently go cross-origin.
const mediaID = this.media.getID() ?? undefined;
return this._renderContainer(html`
${ViewItemClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
@@ -265,6 +266,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
title="${this.media.getTitle() ?? ''}"
url=${url}
.hass=${this.hass}
.targetID=${mediaID}
?controls=${this.viewerConfig.controls.builtin}
>
</advanced-camera-card-ha-hls-player>`
@@ -274,6 +276,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
url=${url}
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
.targetID=${mediaID}
?controls=${this.viewerConfig.controls.builtin}
>
</advanced-camera-card-video-player>
@@ -283,6 +286,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
url="${url}"
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
.targetID=${mediaID}
@click=${() => {
if (this.viewerConfig?.snapshot_click_plays_clip) {
this._switchToRelatedClipView();
+51 -28
View File
@@ -10,12 +10,18 @@
// ====================================================================
import { css, CSSResultGroup, html, nothing, PropertyValues, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import '../components/image-player.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { MediaLoadedInfo, MediaPlayer, MediaPlayerController } from '../types.js';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../utils/media-info.js';
import {
MediaLoadedInfo,
MediaLoadedInfoEventDetail,
MediaPlayer,
MediaPlayerController,
} from '../types.js';
import { onAbort } from '../utils/abort-signal.js';
import './ha-hls-player.js';
import './ha-web-rtc-player.js';
@@ -45,8 +51,21 @@ customElements.whenDefined('ha-camera-stream').then(() => {
@query('.player:not(.hidden)')
protected _player: MediaPlayer;
@property({ attribute: false })
public targetID?: string;
// ha-camera-stream renders up to three inner players (MJPEG / HLS /
// WebRTC), only one visible. Inner leaves all fire `media:loaded`
// independently — we suppress those at this boundary (`stopPropagation` in
// `_captureInnerLoad`), cache the latest per type, and republish the
// visible one's info via our own source controller in `updated()`.
private _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {};
private _mediaLoadedInfoDispatched: MediaLoadedInfo | null = null;
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
this,
{
getTargetID: () => this.targetID ?? null,
},
);
// ========================================================================================
// Minor modifications from:
@@ -58,16 +77,20 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return (await this._player?.getMediaPlayerController()) ?? null;
}
private _storeMediaLoadedInfoHandler(
private _captureInnerLoad(
stream: StreamType,
ev: CustomEvent<MediaLoadedInfo>,
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) {
this._storeMediaLoadedInfo(stream, ev.detail);
// Stop the inner-leaf event at the aggregator boundary; the visible
// stream's info is republished via this aggregator's own source
// controller in updated().
ev.stopPropagation();
this._mediaLoadedInfoPerStream[stream] = ev.detail.info;
onAbort(ev.detail.signal, () => {
if (this._mediaLoadedInfoPerStream[stream] === ev.detail.info) {
delete this._mediaLoadedInfoPerStream[stream];
}
private _storeMediaLoadedInfo(stream: StreamType, mediaLoadedInfo: MediaLoadedInfo) {
this._mediaLoadedInfoPerStream[stream] = mediaLoadedInfo;
});
this.requestUpdate();
}
@@ -78,10 +101,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
if (stream.type === STREAM_TYPE_MJPEG) {
return html`
<advanced-camera-card-image-player
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._storeMediaLoadedInfo(STREAM_TYPE_MJPEG, ev.detail);
ev.stopPropagation();
}}
.targetID=${this.targetID}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._captureInnerLoad(STREAM_TYPE_MJPEG, ev)}
src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl || ''}
@@ -101,10 +124,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._storeMediaLoadedInfoHandler(STREAM_TYPE_HLS, ev);
ev.stopPropagation();
}}
.targetID=${this.targetID}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._captureInnerLoad(STREAM_TYPE_HLS, ev)}
@streams=${this._handleHlsStreams}
class="player ${stream.visible ? '' : 'hidden'}"
></advanced-camera-card-ha-hls-player>`;
@@ -119,10 +142,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._storeMediaLoadedInfoHandler(STREAM_TYPE_WEB_RTC, ev);
ev.stopPropagation();
}}
.targetID=${this.targetID}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._captureInnerLoad(STREAM_TYPE_WEB_RTC, ev)}
@streams=${this._handleWebRtcStreams}
class="player ${stream.visible ? '' : 'hidden'}"
></advanced-camera-card-ha-web-rtc-player>`;
@@ -141,13 +164,13 @@ customElements.whenDefined('ha-camera-stream').then(() => {
this.muted,
);
// Republish the visible stream's cached info as our own.
const visibleStream = streams.find((stream) => stream.visible) ?? null;
if (visibleStream) {
const mediaLoadedInfo = this._mediaLoadedInfoPerStream[visibleStream.type];
if (mediaLoadedInfo && mediaLoadedInfo !== this._mediaLoadedInfoDispatched) {
this._mediaLoadedInfoDispatched = mediaLoadedInfo;
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
}
const mediaLoadedInfo = visibleStream
? this._mediaLoadedInfoPerStream[visibleStream.type]
: null;
if (mediaLoadedInfo) {
this._mediaLoadedInfoSourceController.set(mediaLoadedInfo);
}
}
+17 -3
View File
@@ -10,9 +10,10 @@
// ====================================================================
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../components-lib/media-player/video.js';
import { renderNotificationBlockFromText } from '../components/notification/block.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
@@ -24,7 +25,7 @@ import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/controls.js';
import {
dispatchMediaLoadedEvent,
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
@@ -42,12 +43,22 @@ customElements.whenDefined('ha-hls-player').then(() => {
@query('#video')
protected _video: HTMLVideoElement;
@property({ attribute: false })
public targetID?: string;
private _mediaPlayerController = new VideoMediaPlayerController(
this,
() => this._video,
() => this.controls,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
this,
{
getTargetID: () => this.targetID ?? null,
},
);
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
@@ -93,7 +104,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
private _loadedDataHandler(ev: Event) {
super._loadedData();
dispatchMediaLoadedEvent(this, ev, {
const info = createMediaLoadedInfo(ev, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
@@ -101,6 +112,9 @@ customElements.whenDefined('ha-hls-player').then(() => {
},
technology: ['hls'],
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
}
static get styles(): CSSResultGroup {
+23 -5
View File
@@ -10,9 +10,10 @@
// ====================================================================
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../components-lib/media-player/video.js';
import { renderNotificationBlockFromText } from '../components/notification/block.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
@@ -27,7 +28,7 @@ import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/controls.js';
import {
dispatchMediaLoadedEvent,
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
@@ -42,12 +43,22 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
@customElement('advanced-camera-card-ha-web-rtc-player')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer {
@property({ attribute: false })
public targetID?: string;
private _mediaPlayerController = new VideoMediaPlayerController(
this,
() => this._videoEl,
() => this.controls,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
this,
{
getTargetID: () => this.targetID ?? null,
},
);
protected _audioTracksMuteStateCleanup: AudioTracksMuteStateCleanup = null;
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -130,7 +141,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
private _loadedDataHandler(ev: Event) {
super._loadedData();
dispatchMediaLoadedEvent(this, ev, {
const info = createMediaLoadedInfo(ev, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
@@ -138,13 +149,17 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
},
technology: ['webrtc'],
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
// Listen for audio track mute/unmute changes and re-dispatch
// Re-report on audio track mute/unmute changes so the parent's
// capabilities reflect the current state.
this._audioTracksMuteStateCleanup?.();
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(
this._peerConnection,
() => {
dispatchMediaLoadedEvent(this, this._videoEl, {
const info = createMediaLoadedInfo(this._videoEl, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
@@ -152,6 +167,9 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
},
technology: ['webrtc'],
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
},
);
}
+32 -3
View File
@@ -43,9 +43,28 @@ export interface MediaLoadedInfo {
mediaPlayerController?: MediaPlayerController;
capabilities?: MediaLoadedCapabilities;
// Whether or not this media is a placeholder (temporary image) whilst another
// media item is being loaded.
placeholder?: boolean;
// Universal key identifying "what this media belongs to" — a camera ID for
// live, a media ID for the viewer, or a sentinel for the image view.
targetID?: string;
}
export type UntargetedMediaLoadedInfo = Omit<MediaLoadedInfo, 'targetID'>;
// Opaque token used to tag the source of a MediaLoadedInfo entry. The
// dispatching element from the source controller's bubble path is always an
// HTMLElement, and reference equality is the only operation we perform on it.
export type MediaLoadedInfoOwner = HTMLElement;
export interface MediaLoadedInfoEventDetail {
info: MediaLoadedInfo;
// Aborts when the source retires this media. The source aborts on host
// disconnect, and when a subsequent `set()` arrives under a different
// `targetID` (replacing this dispatch). Independent of DOM connectedness, so
// cleanup works after `parentNode` becomes null. Recipients along the bubble
// path register cleanup synchronously while handling the load event with
// `signal.addEventListener('abort', callback)`.
signal: AbortSignal;
}
export type WebkitHTMLVideoElement = HTMLVideoElement & {
@@ -179,3 +198,13 @@ export interface EffectsManagerInterface {
setContainer(container: EffectsContainer): void;
removeContainer(): void;
}
// Type the cus-tom `media:loaded` event globally so `addEventListener` and
// `removeEventListener` accept a properly-typed handler on any HTMLElement
// without an `as` cast. Standard TS pattern via module augmentation of the
// platform's event-map interfaces.
declare global {
interface HTMLElementEventMap {
'advanced-camera-card:media:loaded': CustomEvent<MediaLoadedInfoEventDetail>;
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Register a cleanup callback to fire when an `AbortSignal` aborts. Unlike
* `signal.addEventListener('abort', cb)` directly, this fires the callback
* immediately if the signal is already aborted.
*/
export const onAbort = (signal: AbortSignal, callback: () => void): void => {
if (signal.aborted) {
callback();
} else {
signal.addEventListener('abort', callback, { once: true });
}
};
-20
View File
@@ -99,26 +99,6 @@ export class CarouselController {
return;
}
this._carousel.scrollTo(index, this._transitionEffect === 'none');
// This event exists to allow the caller to know the difference between
// programatically force slide selections and user-driven slide selections
// (e.g. carousel drags). See the note in auto-media-loaded-info.ts on how
// this is used.
const newSlide = this.getSlide(index);
/* istanbul ignore if: defensive guard for getSlide returning null which can
only happen with an index out of bounds, which is guarded against above --
@preserve */
if (newSlide) {
fireAdvancedCameraCardEvent<CarouselSelected>(
this._parent,
'carousel:force-select',
{
index: index,
element: newSlide,
},
);
}
}
private _refreshCarouselContents = (): void => {
@@ -1,143 +0,0 @@
import { EmblaCarouselType } from 'embla-carousel';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { MediaLoadedInfo } from '../../../../types';
import {
AdvancedCameraCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../../../media-info';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMediaLoadedInfo?: AutoMediaLoadedInfoType;
}
}
/**
* On the relationship between carousel:select and carousel:force-select:
*
* There is a complex interplay here. `carousel:force-select` is an event
* dispatched by the carousel when it is forced to select a particular slide
* (i.e. the view has changed). `carousel:select` is dispatched for any
* selection -- forced or human (e.g. the user dragging the carousel).
*
* The media info should only be dispatched _after_ the view object has been
* updated (since the view will clear the loaded media info). The setting of the
* view (trigged by `carousel:select`) may require async fetches and may take a
* while -- and so if the card dispatched media on `carousel:selecte` then the
* media info may be dispatched before the view is set (which could result in
* the dispatched media immediately being cleared by the view).
*
* It is fine to have media info dispatched from the `carousel:init` event,
* since the carousel will be initialized based on a particular view object. In
* practice, the carousel will be initialized before the media is loaded, so
* there may not be anything to dispatch at that point.
*
* When media is loaded, that media loaded info will always be allowed to
* propogate upwards as long as it is selected.
*/
type AutoMediaLoadedInfoType = CreatePluginType<LoosePluginType, LooseOptionsType>;
function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
let emblaApi: EmblaCarouselType;
let slides: (HTMLElement & AdvancedCameraCardMediaLoadedEventTarget)[] = [];
const mediaLoadedInfo: MediaLoadedInfo[] = [];
function init(emblaApiInstance: EmblaCarouselType): void {
emblaApi = emblaApiInstance;
slides = emblaApi.slideNodes();
for (const slide of slides) {
slide.addEventListener(
'advanced-camera-card:media:loaded',
mediaLoadedInfoHandler,
);
slide.addEventListener(
'advanced-camera-card:media:unloaded',
mediaUnloadedInfoHandler,
);
}
emblaApi.on('init', slideSelectHandler);
emblaApi
.containerNode()
.addEventListener(
'advanced-camera-card:carousel:force-select',
slideSelectHandler,
);
}
function destroy(): void {
for (const slide of slides) {
slide.removeEventListener(
'advanced-camera-card:media:loaded',
mediaLoadedInfoHandler,
);
slide.removeEventListener(
'advanced-camera-card:media:unloaded',
mediaUnloadedInfoHandler,
);
}
emblaApi.off('init', slideSelectHandler);
emblaApi
.containerNode()
.removeEventListener(
'advanced-camera-card:carousel:force-select',
slideSelectHandler,
);
}
function mediaLoadedInfoHandler(ev: CustomEvent<MediaLoadedInfo>): void {
const eventPath = ev.composedPath();
// As an optimization, the most recent slide is the one at the end. That's
// where most users are spending time, so start the search there.
for (const [index, slide] of [...slides.entries()].reverse()) {
if (eventPath.includes(slide)) {
mediaLoadedInfo[index] = ev.detail;
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function mediaUnloadedInfoHandler(ev: CustomEvent): void {
const eventPath = ev.composedPath();
for (const [index, slide] of slides.entries()) {
if (eventPath.includes(slide)) {
delete mediaLoadedInfo[index];
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function slideSelectHandler(): void {
const index = emblaApi.selectedScrollSnap();
const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index];
if (savedMediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(
// Event is redispatched from source element.
slides[index],
savedMediaLoadedInfo,
);
}
}
const self: AutoMediaLoadedInfoType = {
name: 'autoMediaLoadedInfo',
options: {},
init,
destroy,
};
return self;
}
export default AutoMediaLoadedInfo;
+7 -83
View File
@@ -3,6 +3,7 @@ import {
MediaLoadedInfo,
MediaPlayerController,
MediaTechnology,
UntargetedMediaLoadedInfo,
} from '../types.js';
import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js';
@@ -10,9 +11,12 @@ const MEDIA_INFO_HEIGHT_CUTOFF = 50;
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
/**
* Create a MediaLoadedInfo object.
* Create a MediaLoadedInfo object. `targetID` is intentionally NOT an option
* — it's owned by the source controller (`MediaLoadedInfoSourceController`)
* and injected at dispatch time, so leaves don't have to (and can't) plumb
* it through info construction.
* @param source An event or HTMLElement that should be used as a source.
* @returns A new MediaLoadedInfo object or null if one could not be created.
* @returns A new info or null if one could not be created.
*/
export function createMediaLoadedInfo(
source: Event | HTMLElement,
@@ -21,7 +25,7 @@ export function createMediaLoadedInfo(
capabilities?: MediaLoadedCapabilities;
technology?: MediaTechnology[];
},
): MediaLoadedInfo | null {
): UntargetedMediaLoadedInfo | null {
let target: HTMLElement | EventTarget;
if (source instanceof Event) {
target = source.composedPath()[0];
@@ -52,46 +56,6 @@ export function createMediaLoadedInfo(
return null;
}
/**
* Dispatch an Advanced Camera Card media loaded event.
* @param element The element to send the event.
* @param source An event or HTMLElement that should be used as a source.
*/
export function dispatchMediaLoadedEvent(
target: HTMLElement,
source: Event | HTMLElement,
options?: {
mediaPlayerController?: MediaPlayerController;
capabilities?: MediaLoadedCapabilities;
technology?: MediaTechnology[];
},
): void {
const mediaLoadedInfo = createMediaLoadedInfo(source, options);
if (mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(target, mediaLoadedInfo);
}
}
/**
* Dispatch a pre-existing MediaLoadedInfo object as an event.
* @param element The element to send the event.
* @param mediaLoadedInfo The MediaLoadedInfo object to send.
*/
export function dispatchExistingMediaLoadedInfoAsEvent(
target: EventTarget,
mediaLoadedInfo: MediaLoadedInfo,
): void {
fireAdvancedCameraCardEvent<MediaLoadedInfo>(target, 'media:loaded', mediaLoadedInfo);
}
/**
* Dispatch a media unloaded event.
* @param element The element to send the event.
*/
export function dispatchMediaUnloadedEvent(element: HTMLElement): void {
fireAdvancedCameraCardEvent(element, 'media:unloaded');
}
export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void {
fireAdvancedCameraCardEvent(target, 'media:volumechange');
}
@@ -114,43 +78,3 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
);
}
// Facilitates correct typing of event handlers.
export interface AdvancedCameraCardMediaLoadedEventTarget extends EventTarget {
addEventListener(
event: 'advanced-camera-card:media:loaded',
listener: (
this: AdvancedCameraCardMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>,
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
event: 'advanced-camera-card:media:unloaded',
listener: (this: AdvancedCameraCardMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'advanced-camera-card:media:loaded',
listener: (
this: AdvancedCameraCardMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
event: 'advanced-camera-card:media:unloaded',
listener: (this: AdvancedCameraCardMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
+8 -3
View File
@@ -1,8 +1,9 @@
import { CameraManager } from '../camera-manager/manager';
import { PTZAction } from '../config/schema/actions/custom/ptz';
import { PTZCapabilities } from '../types';
import { View } from '../view/view';
import { getViewTargetID } from '../view/target-id';
import { View } from '../view/view';
import { getStreamCameraID } from './substream';
export type PTZType = 'digital' | 'ptz';
interface PTZTarget {
@@ -17,7 +18,12 @@ export const getPTZTarget = (
cameraManager?: CameraManager;
},
): PTZTarget | null => {
const targetID = getViewTargetID(view);
// PTZ is a playback-layer concern: for live, commands target the *actual*
// streaming camera (substream-aware), and capability checks must consult
// the substream too (a base camera with no native PTZ may still expose
// PTZ via its substream). For viewer/image, the logical view target is
// already correct.
const targetID = view.is('live') ? getStreamCameraID(view) : getViewTargetID(view);
if (!targetID) {
return null;
}
@@ -30,7 +36,6 @@ export const getPTZTarget = (
}
if (view.is('live')) {
let type: PTZType = 'digital';
if (options?.type !== 'digital' && options?.cameraManager) {
if (hasCameraTruePTZ(options.cameraManager, targetID)) {
type = 'ptz';
+5 -6
View File
@@ -1,4 +1,3 @@
import { getStreamCameraID } from '../utils/substream';
import { View } from './view';
// Synthetic target ID used for the image view. The image view has no natural
@@ -9,16 +8,16 @@ export const IMAGE_VIEW_TARGET_ID_SENTINEL = '__IMAGE_VIEW__';
// Returns a universal target identifier for the current view — the single key
// used by PTZ/zoom state and media retry epochs to identify "what is currently
// being displayed." Distinct from a raw camera ID because it accounts for
// substreams (live) and the image view sentinel. Media IDs, camera IDs, and
// the image sentinel inhabit distinct namespaces so there are no collisions
// across view types.
// being displayed." For live, this is the *base* camera ID (substream is an
// implementation detail of how to play camera X, not a separate logical
// identity — see `getStreamCameraID` for the substream-aware variant used
// only inside the playback chain).
export const getViewTargetID = (view: View): string | null => {
if (view.isViewerView()) {
return view.queryResults?.getSelectedResult()?.getID() ?? null;
}
if (view.is('live')) {
return getStreamCameraID(view);
return view.camera;
}
if (view.is('image')) {
return IMAGE_VIEW_TARGET_ID_SENTINEL;
+324 -23
View File
@@ -1,61 +1,362 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager';
import { createCardAPI, createMediaLoadedInfo } from '../test-utils.js';
import {
createCardAPI,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
} from '../test-utils.js';
// @vitest-environment jsdom
describe('MediaLoadedInfoManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
manager.initialize();
expect(api.getConditionStateManager().setState).toBeCalledWith({
mediaLoadedInfo: null,
});
});
it('should set', () => {
describe('set', () => {
it('should surface info and fire condition state for the selected target', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const mediaInfo = createMediaLoadedInfo();
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.set(mediaInfo);
manager.setSelected('target-1');
vi.clearAllMocks();
manager.set(info, owner);
expect(manager.has()).toBeTruthy();
expect(manager.get()).toBe(mediaInfo);
expect(api.getConditionStateManager().setState).toBeCalledWith(
expect.objectContaining({ mediaLoadedInfo: mediaInfo }),
);
expect(manager.get()).toBe(info);
expect(api.getConditionStateManager().setState).toBeCalledWith({
mediaLoadedInfo: info,
});
expect(api.getStyleManager().setExpandedMode).toBeCalled();
expect(api.getCardElementManager().update).toBeCalled();
});
it('should not set invalid media info', () => {
it('should cache info for non-selected targets without side effects', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const mediaLoadedInfo = createMediaLoadedInfo({ width: 0, height: 0 });
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.set(mediaLoadedInfo);
manager.set(info, owner);
expect(manager.has()).toBeFalsy();
expect(manager.get()).toBeNull();
expect(api.getConditionStateManager().setState).not.toBeCalled();
});
expect(api.getStyleManager().setExpandedMode).not.toBeCalled();
expect(api.getCardElementManager().update).not.toBeCalled();
it('should get last known', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const mediaLoadedInfo = createMediaLoadedInfo();
manager.set(mediaLoadedInfo);
manager.setSelected('target-1');
expect(manager.has()).toBeTruthy();
expect(manager.get()).toBe(info);
expect(api.getConditionStateManager().setState).toBeCalledWith({
mediaLoadedInfo: info,
});
});
it('should reject info missing dimensions', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({
width: 0,
height: 0,
targetID: 'target-1',
});
manager.setSelected('target-1');
vi.clearAllMocks();
manager.set(info, owner);
expect(manager.has()).toBeFalsy();
expect(manager.get()).toBeNull();
expect(api.getConditionStateManager().setState).not.toBeCalled();
expect(api.getStyleManager().setExpandedMode).not.toBeCalled();
expect(api.getCardElementManager().update).not.toBeCalled();
});
it('should reject info without a targetID', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: undefined });
manager.setSelected('target-1');
manager.set(info, owner);
expect(manager.has()).toBeFalsy();
});
});
describe('setSelected', () => {
it('should switch between targets', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner1 = document.createElement('div');
const owner2 = document.createElement('div');
const info1 = createMediaLoadedInfo({ targetID: 'target-1' });
const info2 = createMediaLoadedInfo({ targetID: 'target-2' });
manager.set(info1, owner1);
manager.set(info2, owner2);
manager.setSelected('target-1');
expect(manager.get()).toBe(info1);
manager.setSelected('target-2');
expect(manager.get()).toBe(info2);
manager.setSelected(null);
expect(manager.get()).toBeNull();
});
it('should be a no-op when re-selecting the same target', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
manager.setSelected('target-1');
vi.clearAllMocks();
manager.setSelected('target-1');
expect(api.getConditionStateManager().setState).not.toBeCalled();
});
it('should emit null condition state when selecting a target with no info', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
manager.setSelected('target-1');
expect(api.getConditionStateManager().setState).toBeCalledWith({
mediaLoadedInfo: null,
});
});
});
describe('getLastKnown', () => {
it('should return the last known info for the selected target', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.setSelected('target-1');
manager.set(info, owner);
expect(manager.getLastKnown()).toBe(info);
});
it('should return null when nothing is selected', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
expect(manager.getLastKnown()).toBeNull();
});
it('should preserve last known across clear', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.setSelected('target-1');
manager.set(info, owner);
manager.clear();
expect(manager.has()).toBeFalsy();
expect(manager.getLastKnown()).toBe(info);
});
});
describe('handleLoadEvent', () => {
it('should register info from a valid event', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const source = document.createElement('div');
const ac = new AbortController();
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.setSelected('target-1');
vi.clearAllMocks();
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
);
expect(manager.get()).toBe(info);
expect(api.getConditionStateManager().setState).toBeCalledWith({
mediaLoadedInfo: info,
});
});
it('should ignore events whose composedPath()[0] is not an HTMLElement', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const ac = new AbortController();
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const ev = createMediaLoadedInfoEvent({ info, signal: ac.signal });
// Force the path to look like a non-HTMLElement (e.g. a window).
Object.defineProperty(ev, 'composedPath', { value: () => [window] });
manager.setSelected('target-1');
manager.handleLoadEvent(ev);
expect(manager.has()).toBeFalsy();
});
it('should ignore events whose info has no targetID', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const source = document.createElement('div');
const ac = new AbortController();
const info = createMediaLoadedInfo({ targetID: undefined });
manager.setSelected('target-1');
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
);
expect(manager.has()).toBeFalsy();
});
it('should clear an entry on signal abort', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const source = document.createElement('div');
const ac = new AbortController();
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.setSelected('target-1');
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
);
expect(manager.has()).toBeTruthy();
ac.abort();
expect(manager.has()).toBeFalsy();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
mediaLoadedInfo: null,
});
});
it('should not clear an entry whose owner has been replaced when an older signal aborts', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner1 = document.createElement('div');
const owner2 = document.createElement('div');
const ac1 = new AbortController();
const ac2 = new AbortController();
const info1 = createMediaLoadedInfo({ targetID: 'target-1' });
const info2 = createMediaLoadedInfo({ targetID: 'target-1' });
manager.setSelected('target-1');
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source: owner1, info: info1, signal: ac1.signal }),
);
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source: owner2, info: info2, signal: ac2.signal }),
);
expect(manager.get()).toBe(info2);
// The first owner's signal aborts, but it shouldn't blow away the entry
// owner2 has since taken over.
ac1.abort();
expect(manager.get()).toBe(info2);
});
it('should not fire condition state when clearing a non-selected target', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const source = document.createElement('div');
const ac = new AbortController();
const info = createMediaLoadedInfo({ targetID: 'target-1' });
// Select a different target than the one being cleared.
manager.setSelected('target-other');
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
);
vi.clearAllMocks();
ac.abort();
expect(api.getConditionStateManager().setState).not.toBeCalled();
});
});
describe('clear', () => {
it('should drop active entries and fire condition state when selected target had info', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.setSelected('target-1');
manager.set(info, owner);
vi.clearAllMocks();
manager.clear();
expect(manager.has()).toBeFalsy();
expect(manager.getLastKnown()).toBe(mediaLoadedInfo);
expect(api.getConditionStateManager().setState).toBeCalledWith(
expect.objectContaining({ mediaLoadedInfo }),
);
expect(api.getConditionStateManager().setState).toBeCalledWith({
mediaLoadedInfo: null,
});
});
it('should be a no-op on condition state when nothing is selected', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
manager.clear();
expect(api.getConditionStateManager().setState).not.toBeCalled();
});
it('should not fire condition state when the selected target has no info', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
manager.setSelected('target-1');
vi.clearAllMocks();
manager.clear();
expect(api.getConditionStateManager().setState).not.toBeCalled();
});
});
describe('initialize', () => {
it('should clear active state, last-known and selected', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
manager.setSelected('target-1');
manager.set(info, owner);
manager.initialize();
expect(manager.get()).toBeNull();
expect(manager.getLastKnown()).toBeNull();
// Re-selecting `target-1` after initialize should produce no last-known.
manager.setSelected('target-1');
expect(manager.getLastKnown()).toBeNull();
});
});
});
@@ -43,7 +43,7 @@ describe('should act correctly when view is set', () => {
expect(manager.getView()).toBe(view);
expect(manager.hasView()).toBeTruthy();
expect(api.getMediaLoadedInfoManager().clear).toBeCalled();
expect(api.getMediaLoadedInfoManager().setSelected).toBeCalledWith('camera');
expect(api.getCardElementManager().scrollReset).toBeCalled();
expect(api.getStyleManager().setExpandedMode).toBeCalled();
expect(api.getConditionStateManager()?.setState).toBeCalledWith({
@@ -55,7 +55,7 @@ describe('should act correctly when view is set', () => {
expect(api.getCardElementManager().update).toBeCalled();
});
it('should set view with minor changes without media clearing or scroll', () => {
it('should set view with minor changes without scroll', () => {
const view_1 = createView({
view: 'live',
camera: 'camera',
@@ -68,7 +68,6 @@ describe('should act correctly when view is set', () => {
manager.setViewDefault();
vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear();
vi.mocked(api.getCardElementManager().scrollReset).mockClear();
const view_2 = createView({
@@ -82,9 +81,7 @@ describe('should act correctly when view is set', () => {
expect(manager.getView()).toBe(view_2);
// The new view is neither a major media change, nor a different view name,
// so media clearing and scrolling should not happen.
expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled();
// Same view name, so scrolling should not happen.
expect(api.getCardElementManager().scrollReset).not.toBeCalled();
});
});
@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LiveController } from '../../../src/components-lib/live/live-controller';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../src/utils/media-info';
import {
IntersectionObserverMock,
callIntersectionHandler,
createLitElement,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
createParent,
} from '../../test-utils';
// @vitest-environment jsdom
describe('LiveController', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
});
it('should be constructable', () => {
const controller = new LiveController(createLitElement());
expect(controller).toBeTruthy();
});
it('should connect and disconnect', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('advanced-camera-card:media:loaded', eventListener);
const controller = new LiveController(host);
expect(host.addController).toBeCalled();
controller.hostConnected();
callIntersectionHandler(false);
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
expect(eventListener).toBeCalledTimes(0);
controller.hostDisconnected();
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
expect(eventListener).toBeCalledTimes(1);
});
describe('should handle background / foreground', () => {
it('should start in the foreground', () => {
const controller = new LiveController(createLitElement());
expect(controller.isInBackground()).toBeFalsy();
});
it('should handle changing to background', () => {
const element = createLitElement();
const controller = new LiveController(element);
expect(controller.isInBackground()).toBeFalsy();
expect(element.requestUpdate).toBeCalledTimes(0);
callIntersectionHandler(true);
expect(controller.isInBackground()).toBeFalsy();
expect(element.requestUpdate).toBeCalledTimes(0);
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
expect(element.requestUpdate).toBeCalledTimes(1);
});
it('should dispatch media loaded on background change', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('advanced-camera-card:media:loaded', eventListener);
const controller = new LiveController(host);
const mediaLoadedInfo = createMediaLoadedInfo();
controller.hostConnected();
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
expect(eventListener).toBeCalledTimes(0);
callIntersectionHandler(true);
expect(eventListener).toBeCalledTimes(1);
expect(eventListener).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: mediaLoadedInfo,
}),
);
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
expect(eventListener).toBeCalledTimes(2);
});
});
});
@@ -5,6 +5,7 @@ import { MediaLoadedInfo } from '../../src/types';
import {
callResizeHandler,
createLitElement,
createMediaLoadedInfoEvent,
getResizeObserver,
ResizeObserverMock,
} from '../test-utils';
@@ -676,9 +677,7 @@ describe('MediaDimensionsContainerController', () => {
height: 160,
};
innerContainer.dispatchEvent(
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
detail: mediaLoadedInfo,
}),
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
);
expect(host.hasAttribute('rotated')).toBeTruthy();
@@ -713,18 +712,14 @@ describe('MediaDimensionsContainerController', () => {
height: 160,
};
innerContainer.dispatchEvent(
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
detail: mediaLoadedInfo,
}),
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
);
expect(host.hasAttribute('rotated')).toBeTruthy();
host.removeAttribute('rotated');
innerContainer.dispatchEvent(
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
detail: mediaLoadedInfo,
}),
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
);
expect(host.hasAttribute('rotated')).toBeFalsy();
@@ -6,8 +6,6 @@ import {
MediaGridConstructorOptions,
MediaGridController,
} from '../../src/components-lib/media-grid-controller';
import { MediaLoadedInfo } from '../../src/types';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
import {
MutationObserverMock,
ResizeObserverMock,
@@ -82,11 +80,6 @@ const triggerResizeObserver = (hostOrCell: 'cell' | 'host'): void => {
// @vitest-environment jsdom
describe('MediaGridController', () => {
const mediaLoadedInfo: MediaLoadedInfo = {
width: 10,
height: 20,
};
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('MutationObserver', MutationObserverMock);
@@ -175,59 +168,6 @@ describe('MediaGridController', () => {
expect(controller.getSelected()).toBe('0');
});
it('should dispatch media loaded info on selection', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
controller.selectCell('0');
expect(mediaLoadedInfoHandler).toBeCalledWith(
expect.objectContaining({
detail: mediaLoadedInfo,
}),
);
});
it('should dispatch media loaded info when cell is selected', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
controller.selectCell('0');
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
expect(mediaLoadedInfoHandler).toBeCalledWith(
expect.objectContaining({
detail: mediaLoadedInfo,
}),
);
});
it('should not dispatch media loaded info when cell is not selected', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host);
controller.selectCell('1');
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
// Another element is selected, so the event should not have propagated.
expect(mediaLoadedInfoHandler).not.toBeCalled();
});
it('should unselect', () => {
const children = createChildren();
const slot = createSlot();
@@ -235,12 +175,10 @@ describe('MediaGridController', () => {
const controller = createController(host);
const unselectedHandler = vi.fn();
const unloadMediaHandler = vi.fn();
host.addEventListener(
'advanced-camera-card:media-grid:unselected',
unselectedHandler,
);
host.addEventListener('advanced-camera-card:media:unloaded', unloadMediaHandler);
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
@@ -257,15 +195,13 @@ describe('MediaGridController', () => {
expect(child.getAttribute('unselected')).toEqual('');
}
// Expect handlers to have been called.
// The grid signals its own state change via media-grid:unselected.
expect(unselectedHandler).toBeCalledTimes(1);
expect(unloadMediaHandler).toBeCalledTimes(1);
// Unselecting a second time should do nothing.
controller.unselectAll();
expect(unselectedHandler).toBeCalledTimes(1);
expect(unloadMediaHandler).toBeCalledTimes(1);
});
it('should select in constructor', () => {
@@ -315,7 +251,6 @@ describe('MediaGridController', () => {
const children = createChildren();
const parent = createParent({ children: children });
const controller = createController(parent, { selected: '1' });
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
expect(controller.getSelected()).toBe('1');
expect(controller.getGridSize()).toBe(3);
@@ -0,0 +1,322 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaLoadedInfoSinkController } from '../../src/components-lib/media-loaded-info-sink-controller';
import {
createLitElement,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
} from '../test-utils';
// @vitest-environment jsdom
describe('MediaLoadedInfoSinkController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should register itself with the host', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-1',
});
expect(host.addController).toBeCalledWith(controller);
});
it('should default to an empty info', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-1',
});
expect(controller.has()).toBeFalsy();
expect(controller.get()).toBeNull();
});
it('should return null when getTargetID returns null', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => null,
});
controller.hostConnected();
host.dispatchEvent(createMediaLoadedInfoEvent());
expect(controller.has()).toBeFalsy();
expect(controller.get()).toBeNull();
});
describe('per-target caching', () => {
it('should expose only the selected target', () => {
let selected: string | null = 'target-A';
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => selected,
});
controller.hostConnected();
const infoA = createMediaLoadedInfo({ targetID: 'target-A' });
const infoB = createMediaLoadedInfo({ targetID: 'target-B' });
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoA }));
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoB }));
// Both cached, but only the selected one is exposed.
expect(controller.get()).toBe(infoA);
// Selecting the other target switches what `get()` returns — without a
// new event arriving for it.
selected = 'target-B';
controller.hostUpdated();
expect(controller.get()).toBe(infoB);
});
it('should fire callback when selection changes the active info', () => {
let selected: string | null = 'target-A';
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => selected,
callback,
});
controller.hostConnected();
const infoA = createMediaLoadedInfo({ targetID: 'target-A' });
const infoB = createMediaLoadedInfo({ targetID: 'target-B' });
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoA }));
host.dispatchEvent(createMediaLoadedInfoEvent({ info: infoB }));
vi.clearAllMocks();
// Selection change to a target whose info is already cached.
selected = 'target-B';
controller.hostUpdated();
expect(callback).toBeCalledWith(infoB);
expect(host.requestUpdate).toBeCalled();
});
it('should not fire callback for non-selected target loads', () => {
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-A',
callback,
});
controller.hostConnected();
vi.clearAllMocks();
// Load for an unselected target — cached but inactive.
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ targetID: 'target-B' }),
}),
);
expect(callback).not.toBeCalled();
expect(host.requestUpdate).not.toBeCalled();
});
it('should fire callback for selected target loads', () => {
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-A',
callback,
});
controller.hostConnected();
vi.clearAllMocks();
const info = createMediaLoadedInfo({ targetID: 'target-A' });
host.dispatchEvent(createMediaLoadedInfoEvent({ info }));
expect(callback).toBeCalledWith(info);
expect(host.requestUpdate).toBeCalled();
});
it('should not re-fire callback when hostUpdated runs without a targetID change', () => {
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-A',
callback,
});
controller.hostConnected();
const info = createMediaLoadedInfo({ targetID: 'target-A' });
host.dispatchEvent(createMediaLoadedInfoEvent({ info }));
vi.clearAllMocks();
// Repeated host updates with no targetID change: no callback re-fire.
controller.hostUpdated();
controller.hostUpdated();
expect(callback).not.toBeCalled();
});
it('should not fire callback when selection switches between empty targets', () => {
let selected: string | null = 'target-A';
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => selected,
callback,
});
controller.hostConnected();
controller.hostUpdated();
vi.clearAllMocks();
// Switch to another target with no cached info — active stays null.
selected = 'target-B';
controller.hostUpdated();
expect(callback).not.toBeCalled();
});
it('should ignore events whose info has no targetID', () => {
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-A',
callback,
});
controller.hostConnected();
vi.clearAllMocks();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ targetID: undefined }),
}),
);
expect(callback).not.toBeCalled();
expect(controller.get()).toBeNull();
});
});
describe('hostConnected / hostDisconnected', () => {
it('should add the listener on connect and remove + clear on disconnect', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-1',
});
controller.hostConnected();
const info = createMediaLoadedInfo({ targetID: 'target-1' });
host.dispatchEvent(createMediaLoadedInfoEvent({ info }));
expect(controller.get()).toBe(info);
controller.hostDisconnected();
expect(controller.has()).toBeFalsy();
// Further events should not be observed.
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ targetID: 'target-1', width: 500 }),
}),
);
expect(controller.get()).toBeNull();
});
it('should not fire callback or requestUpdate on disconnect', () => {
// Testing the asymmetry described in the controller's class doc: abort
// path notifies consumers (host still rendering), disconnect path does
// not (host detaching, no UI to update).
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-1',
callback,
});
controller.hostConnected();
host.dispatchEvent(createMediaLoadedInfoEvent());
vi.clearAllMocks();
controller.hostDisconnected();
expect(callback).not.toBeCalled();
expect(host.requestUpdate).not.toBeCalled();
expect(controller.get()).toBeNull();
});
});
describe('on signal abort', () => {
it('should clear the entry and fire callback(null) for the selected target', () => {
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-A',
callback,
});
controller.hostConnected();
const ac = new AbortController();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ targetID: 'target-A' }),
signal: ac.signal,
}),
);
vi.clearAllMocks();
ac.abort();
expect(controller.get()).toBeNull();
expect(callback).toBeCalledWith(null);
expect(host.requestUpdate).toBeCalled();
});
it('should not fire callback when an unselected target aborts', () => {
const callback = vi.fn();
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-A',
callback,
});
controller.hostConnected();
const acA = new AbortController();
const acB = new AbortController();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ targetID: 'target-A' }),
signal: acA.signal,
}),
);
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ targetID: 'target-B' }),
signal: acB.signal,
}),
);
vi.clearAllMocks();
acB.abort();
expect(callback).not.toBeCalled();
});
it('should not clobber a newer entry when an older signal aborts', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSinkController(host, {
getTargetID: () => 'target-A',
});
controller.hostConnected();
const ac1 = new AbortController();
const info1 = createMediaLoadedInfo({ targetID: 'target-A', width: 100 });
host.dispatchEvent(
createMediaLoadedInfoEvent({ info: info1, signal: ac1.signal }),
);
const ac2 = new AbortController();
const info2 = createMediaLoadedInfo({ targetID: 'target-A', width: 200 });
host.dispatchEvent(
createMediaLoadedInfoEvent({ info: info2, signal: ac2.signal }),
);
expect(controller.get()).toBe(info2);
// The first signal aborts after a newer entry has overwritten the cache;
// the newer entry must survive.
ac1.abort();
expect(controller.get()).toBe(info2);
});
});
});
@@ -0,0 +1,274 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MediaLoadedInfoSourceController } from '../../src/components-lib/media-loaded-info-source-controller';
import { MediaPlayerController } from '../../src/types';
import { createLitElement, createMediaLoadedInfo } from '../test-utils';
// @vitest-environment jsdom
describe('MediaLoadedInfoSourceController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should register itself with the host', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
expect(host.addController).toBeCalledWith(controller);
});
describe('set', () => {
it('should reject when getTargetID returns null', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => null,
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
expect(handler).not.toBeCalled();
});
it('should dispatch a bubbling, composed event with info+targetID and signal', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo({ width: 320, height: 240 }));
expect(handler).toBeCalledTimes(1);
const ev = handler.mock.calls[0][0] as CustomEvent;
expect(ev.bubbles).toBe(true);
expect(ev.composed).toBe(true);
// The source controller injects targetID from `getTargetID`; the
// dispatched info carries it regardless of what the caller passed.
expect(ev.detail.info).toEqual({
width: 320,
height: 240,
targetID: 'target-1',
});
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
expect(ev.detail.signal.aborted).toBe(false);
});
it('should dedup structurally-equal info', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
const player = mock<MediaPlayerController>();
controller.set(
createMediaLoadedInfo({ mediaPlayerController: player, technology: ['hls'] }),
);
// Same fields, different object identity. Should not redispatch.
controller.set(
createMediaLoadedInfo({ mediaPlayerController: player, technology: ['hls'] }),
);
expect(handler).toBeCalledTimes(1);
});
it('should redispatch when mediaPlayerController reference differs', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
const player1 = mock<MediaPlayerController>();
const player2 = mock<MediaPlayerController>();
controller.set(createMediaLoadedInfo({ mediaPlayerController: player1 }));
controller.set(createMediaLoadedInfo({ mediaPlayerController: player2 }));
expect(handler).toBeCalledTimes(2);
});
it('should redispatch when getTargetID changes between calls', () => {
let targetID: string | null = 'target-1';
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => targetID,
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
targetID = 'target-2';
controller.set(createMediaLoadedInfo());
expect(handler).toBeCalledTimes(2);
expect((handler.mock.calls[0][0] as CustomEvent).detail.info.targetID).toBe(
'target-1',
);
expect((handler.mock.calls[1][0] as CustomEvent).detail.info.targetID).toBe(
'target-2',
);
});
it('should abort the prior dispatch when targetID changes between calls', () => {
// Without this, the manager would zombie an entry under the old
// targetID — its `onAbort` cleanup never fires because we never aborted
// the prior signal before overwriting `_abort`.
let targetID: string | null = 'target-1';
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => targetID,
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
targetID = 'target-2';
controller.set(createMediaLoadedInfo());
// The prior signal aborted so consumers' cleanup runs.
expect(firstSignal.aborted).toBe(true);
const secondSignal = (handler.mock.calls[1][0] as CustomEvent).detail.signal;
expect(secondSignal.aborted).toBe(false);
});
});
describe('hostConnected', () => {
it('should re-dispatch _lastSet on reconnect with a new AbortController', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
// Disconnect and reconnect — without a fresh `set`.
controller.hostDisconnected();
controller.hostConnected();
expect(handler).toBeCalledTimes(2);
const secondSignal = (handler.mock.calls[1][0] as CustomEvent).detail.signal;
// The original signal aborted on disconnect, the new one is fresh.
expect(firstSignal).not.toBe(secondSignal);
expect(firstSignal.aborted).toBe(true);
expect(secondSignal.aborted).toBe(false);
});
it('should be a no-op when there is nothing to re-dispatch', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.hostConnected();
expect(handler).not.toBeCalled();
});
it('should not redispatch if a registration is already active', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
// Active registration, no disconnect — connect should be a no-op.
controller.hostConnected();
expect(handler).toBeCalledTimes(1);
});
it('should not replay stale info after targetID flips during disconnect', () => {
// Bug scenario: getTargetID flips while we're disconnected; reconnect
// must NOT redispatch the cached info under the stale targetID.
let targetID: string | null = 'target-1';
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => targetID,
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
expect(handler).toBeCalledTimes(1);
controller.hostDisconnected();
// While disconnected, the host's targetID prop flips.
targetID = 'target-2';
controller.hostConnected();
// No re-dispatch — the stale cache was discarded.
expect(handler).toBeCalledTimes(1);
// A subsequent set() under the new target dispatches fresh.
controller.set(createMediaLoadedInfo({ width: 320, height: 240 }));
expect(handler).toBeCalledTimes(2);
expect((handler.mock.calls[1][0] as CustomEvent).detail.info.targetID).toBe(
'target-2',
);
});
});
describe('hostDisconnected', () => {
it('should abort the active controller so consumers clean up', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
const handler = vi.fn();
host.addEventListener('advanced-camera-card:media:loaded', handler);
controller.set(createMediaLoadedInfo());
const signal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
const cleanup = vi.fn();
signal.addEventListener('abort', cleanup);
controller.hostDisconnected();
expect(cleanup).toBeCalled();
expect(signal.aborted).toBe(true);
});
it('should be safe to call when nothing is active', () => {
const host = createLitElement();
const controller = new MediaLoadedInfoSourceController(host, {
getTargetID: () => 'target-1',
});
// Should not throw.
controller.hostDisconnected();
});
});
});
+30 -8
View File
@@ -77,7 +77,12 @@ import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
import { QuerySource } from '../src/query-source';
import { Severity } from '../src/severity';
import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
import {
CapabilitiesRaw,
Interaction,
MediaLoadedInfo,
MediaLoadedInfoEventDetail,
} from '../src/types';
import {
EventViewMedia,
ReviewViewMedia,
@@ -328,18 +333,35 @@ export const createMediaLoadedInfo = (
return {
width: 100,
height: 100,
targetID: 'target-1',
...options,
};
};
export const createMediaLoadedInfoEvent = (
mediaLoadedInfo?: MediaLoadedInfo,
): CustomEvent<MediaLoadedInfo> => {
return new CustomEvent('advanced-camera-card:media:loaded', {
detail: mediaLoadedInfo ?? createMediaLoadedInfo(),
composed: true,
export const createMediaLoadedInfoEvent = (options?: {
info?: MediaLoadedInfo;
signal?: AbortSignal;
// When set, overrides `composedPath()` to return `[source]`. Use this for
// tests that hand the event directly to a handler (e.g. `handleLoadEvent`)
// instead of dispatching it; jsdom only populates `composedPath` on real
// dispatch.
source?: HTMLElement;
}): CustomEvent<MediaLoadedInfoEventDetail> => {
const ev = new CustomEvent<MediaLoadedInfoEventDetail>(
'advanced-camera-card:media:loaded',
{
bubbles: true,
});
composed: true,
detail: {
info: options?.info ?? createMediaLoadedInfo(),
signal: options?.signal ?? new AbortController().signal,
},
},
);
if (options?.source) {
Object.defineProperty(ev, 'composedPath', { value: () => [options.source] });
}
return ev;
};
export const createPerformanceConfig = (config: unknown): PerformanceConfig => {
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest';
import { onAbort } from '../../src/utils/abort-signal';
describe('onAbort', () => {
it('should call the callback when the signal aborts', () => {
const ac = new AbortController();
const cb = vi.fn();
onAbort(ac.signal, cb);
expect(cb).not.toBeCalled();
ac.abort();
expect(cb).toBeCalledTimes(1);
});
it('should call the callback synchronously if the signal is already aborted', () => {
const ac = new AbortController();
ac.abort();
const cb = vi.fn();
onAbort(ac.signal, cb);
expect(cb).toBeCalledTimes(1);
});
it('should fire only once even if the signal aborts repeatedly', () => {
const ac = new AbortController();
const cb = vi.fn();
onAbort(ac.signal, cb);
ac.abort();
// Aborting an AbortController again is a no-op, but verify the listener
// is registered with `once: true` so any synthetic re-fire would be a
// no-op too.
ac.signal.dispatchEvent(new Event('abort'));
expect(cb).toBeCalledTimes(1);
});
});
+2 -23
View File
@@ -1,7 +1,6 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
MutationObserverMock,
callMutationHandler,
@@ -111,41 +110,23 @@ describe('CarouselController', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const forceSelectListener = vi.fn();
parent.addEventListener(
'advanced-camera-card:carousel:force-select',
forceSelectListener,
);
const carousel = new CarouselController(createRoot(), parent);
carousel.selectSlide(4);
expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false);
expect(forceSelectListener).toBeCalledWith(
expect.objectContaining({
detail: { index: 4, element: children[4] },
}),
);
});
it('should not select non-existent slide', () => {
const children = createTestSlideNodes({ n: 10 });
const parent = createParent({ children: children });
const forceSelectListener = vi.fn();
parent.addEventListener(
'advanced-camera-card:carousel:force-select',
forceSelectListener,
);
const carousel = new CarouselController(createRoot(), parent);
carousel.selectSlide(11);
// Should not call scrollTo or fire event because index is out of bounds
// Should not call scrollTo because index is out of bounds.
expect(getEmblaApi()?.scrollTo).not.toBeCalled();
expect(forceSelectListener).not.toBeCalled();
});
it('should dispatch select event', () => {
@@ -191,7 +172,6 @@ describe('CarouselController', () => {
const children = createTestSlideNodes({ n: 1 });
const root = createRoot();
const parent = createParent({ children: children });
const plugins = [AutoMediaLoadedInfo()];
new CarouselController(root, parent, {
direction: 'vertical',
@@ -200,7 +180,6 @@ describe('CarouselController', () => {
dragFree: true,
loop: true,
dragEnabled: false,
plugins: plugins,
textDirection: 'rtl',
});
@@ -219,7 +198,7 @@ describe('CarouselController', () => {
watchDrag: false,
direction: 'rtl',
},
plugins,
[],
);
});
@@ -1,94 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AutoMediaLoadedInfo from '../../../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../../../../src/utils/media-info';
import { createMediaLoadedInfo, createParent } from '../../../../test-utils';
import {
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
// @vitest-environment jsdom
describe('AutoMediaLoadedInfo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoMediaLoadedInfo();
expect(plugin.name).toBe('autoMediaLoadedInfo');
});
it('should destroy', () => {
const plugin = AutoMediaLoadedInfo();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
});
describe('should correctly propogate media load/unload depending on whether media is currently selected', () => {
it.each([
['loaded' as const, true],
['unloaded' as const, true],
['loaded' as const, false],
['unloaded' as const, false],
])('%s', (type: string, selected: boolean) => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
selectedScrollSnap: selected ? 5 : 4,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('advanced-camera-card:media:' + type, mediaLoadedHandler);
if (type === 'loaded') {
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
} else if (type === 'unloaded') {
dispatchMediaUnloadedEvent(children[5]);
}
if (selected) {
expect(mediaLoadedHandler).toBeCalled();
} else {
expect(mediaLoadedHandler).not.toBeCalled();
}
});
});
it('selecting a slide should dispatch a previously saved media loaded info if present', () => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('advanced-camera-card:media:loaded', mediaLoadedHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(4);
emblaApi
.containerNode()
.dispatchEvent(new Event('advanced-camera-card:carousel:force-select'));
expect(mediaLoadedHandler).not.toBeCalled();
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(5);
emblaApi
.containerNode()
.dispatchEvent(new Event('advanced-camera-card:carousel:force-select'));
expect(mediaLoadedHandler).toBeCalled();
});
});
-17
View File
@@ -1,25 +1,8 @@
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { EngineType } from 'embla-carousel/components/Engine';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import { merge } from 'lodash-es';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({
mergeOptions: <TypeA extends LooseOptionsType, TypeB extends LooseOptionsType>(
optionsA: TypeA,
optionsB?: TypeB,
): TypeA => {
return merge({}, optionsA, optionsB);
},
optionsAtMedia: <Type extends LooseOptionsType>(options: Type): Type => {
return options;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [],
});
export const callEmblaHandler = (
emblaApi: EmblaCarouselType | null,
eventName: EmblaEventType,
-71
View File
@@ -3,11 +3,8 @@ import { mock } from 'vitest-mock-extended';
import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaUnloadedEvent,
dispatchMediaVolumeChangeEvent,
isValidMediaLoadedInfo,
} from '../../src/utils/media-info';
@@ -83,74 +80,6 @@ describe('createMediaLoadedInfo', () => {
});
});
// @vitest-environment jsdom
describe('dispatchMediaLoadedEvent', () => {
const options = {
player: mock<MediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
it('should dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:loaded', handler);
// Need to write readonly properties.
const img = document.createElement('img');
Object.defineProperty(img, 'naturalWidth', { value: 10 });
Object.defineProperty(img, 'naturalHeight', { value: 20 });
dispatchMediaLoadedEvent(div, img, options);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: {
width: 10,
height: 20,
...options,
},
}),
);
});
it('should not dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:loaded', handler);
dispatchMediaLoadedEvent(div, div, options);
expect(handler).not.toBeCalled();
});
});
// @vitest-environment jsdom
describe('dispatchExistingMediaLoadedInfoAsEvent', () => {
it('should dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:loaded', handler);
const info = createTestMediaLoadedInfo();
dispatchExistingMediaLoadedInfoAsEvent(div, info);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: info,
}),
);
});
});
// @vitest-environment jsdom
describe('dispatchMediaUnloadedEvent', () => {
it('should dispatch', () => {
const handler = vi.fn();
const div = document.createElement('div');
div.addEventListener('advanced-camera-card:media:unloaded', handler);
dispatchMediaUnloadedEvent(div);
expect(handler).toBeCalled();
});
});
// @vitest-environment jsdom
describe('dispatchMediaVolumeChangeEvent', () => {
it('should dispatch', () => {
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { QueryResults } from '../../src/view/query-results';
import {
getViewTargetID,
IMAGE_VIEW_TARGET_ID_SENTINEL,
} from '../../src/view/target-id';
import { createView, generateViewMediaArray } from '../test-utils';
describe('getViewTargetID', () => {
describe('live', () => {
it('should return the base camera ID', () => {
const view = createView({ view: 'live', camera: 'camera.front_door' });
expect(getViewTargetID(view)).toBe('camera.front_door');
});
it('should return the base camera ID even though substream override is active', () => {
// Substream is a playback-layer detail; targetID stays the base camera
// throughout substream toggles so consumers above the provider don't
// need to know about substreams.
const view = createView({
view: 'live',
camera: 'camera.front_door',
context: {
live: {
overrides: new Map([['camera.front_door', 'camera.front_door_lq']]),
},
},
});
expect(getViewTargetID(view)).toBe('camera.front_door');
});
});
describe('viewer', () => {
it('should return the selected media ID', () => {
const media = generateViewMediaArray({ count: 3 });
const view = createView({
view: 'media',
camera: 'camera.front_door',
queryResults: new QueryResults({ results: media, selectedIndex: 1 }),
});
expect(getViewTargetID(view)).toBe(media[1].getID());
});
it('should return null when there is no selection', () => {
const view = createView({
view: 'media',
camera: 'camera.front_door',
});
expect(getViewTargetID(view)).toBeNull();
});
});
describe('image', () => {
it('should return the sentinel', () => {
const view = createView({ view: 'image', camera: 'camera.front_door' });
expect(getViewTargetID(view)).toBe(IMAGE_VIEW_TARGET_ID_SENTINEL);
});
});
describe('other view types', () => {
it.each(['clips', 'snapshots', 'recordings', 'recording'] as const)(
'should return null for %s',
(viewName) => {
const view = createView({ view: viewName, camera: 'camera.front_door' });
expect(getViewTargetID(view)).toBeNull();
},
);
});
});