refactor: Refactor media loading manager for improved robustness (#2464)
This commit is contained in:
committed by
dermotduffy
parent
47bcce93d3
commit
bc366626f1
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { LitElement, ReactiveController } from 'lit';
|
||||
import { MediaLoadedInfo } from '../../types.js';
|
||||
import {
|
||||
AdvancedCameraCardMediaLoadedEventTarget,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
|
||||
interface LiveViewContext {
|
||||
// A cameraID override (used for dependencies/substreams to force a different
|
||||
// camera to be live rather than the camera selected in the view).
|
||||
overrides?: Map<string, string>;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
live?: LiveViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
interface LastMediaLoadedInfo {
|
||||
mediaLoadedInfo: MediaLoadedInfo;
|
||||
source: EventTarget;
|
||||
}
|
||||
|
||||
type LiveControllerHost = LitElement & AdvancedCameraCardMediaLoadedEventTarget;
|
||||
|
||||
export class LiveController implements ReactiveController {
|
||||
private _host: LiveControllerHost;
|
||||
|
||||
// Whether or not the live view is currently in the background (i.e. preloaded
|
||||
// but not visible).
|
||||
private _inBackground = false;
|
||||
|
||||
// Intersection handler is used to detect when the live view flips between
|
||||
// foreground and background (in preload mode).
|
||||
private _intersectionObserver: IntersectionObserver;
|
||||
|
||||
// MediaLoadedInfo object and target from the underlying live media. In the
|
||||
// case of pre-loading these may be propagated later (from the original
|
||||
// source).
|
||||
private _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null;
|
||||
|
||||
constructor(host: LiveControllerHost) {
|
||||
this._host = host;
|
||||
|
||||
host.addController(this);
|
||||
|
||||
this._intersectionObserver = new IntersectionObserver(
|
||||
this._intersectionHandler.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._intersectionObserver.observe(this._host);
|
||||
|
||||
this._host.addEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
this._handleMediaLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._intersectionObserver.disconnect();
|
||||
|
||||
this._host.removeEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
this._handleMediaLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
public isInBackground(): boolean {
|
||||
return this._inBackground;
|
||||
}
|
||||
|
||||
private _handleMediaLoaded = (ev: CustomEvent<MediaLoadedInfo>): void => {
|
||||
this._lastMediaLoadedInfo = {
|
||||
source: ev.composedPath()[0],
|
||||
mediaLoadedInfo: ev.detail,
|
||||
};
|
||||
|
||||
if (this._inBackground) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
private _intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||
const wasInBackground = this._inBackground;
|
||||
this._inBackground = !entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (!this._inBackground && this._lastMediaLoadedInfo) {
|
||||
// If this isn't being rendered in the background, the last render did not
|
||||
// generate a message and there's a saved MediaInfo, dispatch it upwards.
|
||||
dispatchExistingMediaLoadedInfoAsEvent(
|
||||
// Specifically dispatch the event "where it came from", as otherwise
|
||||
// the intermediate layers (e.g. media-carousel which controls the title
|
||||
// popups) will not re-receive the events.
|
||||
this._lastMediaLoadedInfo.source,
|
||||
this._lastMediaLoadedInfo.mediaLoadedInfo,
|
||||
);
|
||||
}
|
||||
|
||||
if (wasInBackground !== this._inBackground) {
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface LiveViewContext {
|
||||
// A cameraID override (used for dependencies/substreams to force a different
|
||||
// camera to be live rather than the camera selected in the view).
|
||||
overrides?: Map<string, string>;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
live?: LiveViewContext;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AutoUnmuteCondition,
|
||||
} from '../config/schema/common/media-actions.js';
|
||||
import { MediaPlayerElement } from '../types.js';
|
||||
import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info.js';
|
||||
import { Timer } from '../utils/timer.js';
|
||||
|
||||
export interface MediaActionsControllerOptions {
|
||||
@@ -21,7 +20,7 @@ export interface MediaActionsControllerOptions {
|
||||
microphoneMuteSeconds?: number;
|
||||
}
|
||||
|
||||
type RenderRoot = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget;
|
||||
type RenderRoot = HTMLElement;
|
||||
|
||||
/**
|
||||
* General note: Always unmute before playing, since Chrome may pause a piece of
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { CameraDimensionsConfig } from '../config/schema/cameras';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
import { MediaLoadedInfoEventDetail } from '../types';
|
||||
import {
|
||||
aspectRatioToString,
|
||||
setOrRemoveAttribute,
|
||||
setOrRemoveStyleProperty,
|
||||
} from '../utils/basic';
|
||||
import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout';
|
||||
|
||||
const ROTATED_ATTRIBUTE = 'rotated';
|
||||
@@ -33,9 +32,7 @@ export class MediaDimensionsContainerController implements ReactiveController {
|
||||
|
||||
private _dimensionsConfig: CameraDimensionsConfig | null = null;
|
||||
|
||||
private _innerContainer:
|
||||
| (HTMLElement & AdvancedCameraCardMediaLoadedEventTarget)
|
||||
| null = null;
|
||||
private _innerContainer: HTMLElement | null = null;
|
||||
private _outerContainer: HTMLElement | null = null;
|
||||
|
||||
public resize = debounce(this._resize.bind(this), 100, { trailing: true });
|
||||
@@ -81,19 +78,20 @@ export class MediaDimensionsContainerController implements ReactiveController {
|
||||
);
|
||||
}
|
||||
|
||||
private _mediaLoadedHandler = (ev: CustomEvent<MediaLoadedInfo>): void => {
|
||||
private _mediaLoadedHandler = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
|
||||
const info = ev.detail.info;
|
||||
// Only resize if the media dimensions have changed (otherwise the loading
|
||||
// image whilst waiting for the stream, will trigger aresize every second).
|
||||
if (
|
||||
this._mediaDimensions?.width === ev.detail.width &&
|
||||
this._mediaDimensions.height === ev.detail.height
|
||||
this._mediaDimensions?.width === info.width &&
|
||||
this._mediaDimensions.height === info.height
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._mediaDimensions = {
|
||||
width: ev.detail.width,
|
||||
height: ev.detail.height,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
};
|
||||
this.resize();
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isEqual, throttle } from 'lodash-es';
|
||||
import Masonry from 'masonry-layout';
|
||||
import { ViewDisplayConfig } from '../config/schema/common/display';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
import {
|
||||
forceReflow,
|
||||
getChildrenFromElement,
|
||||
@@ -9,11 +8,6 @@ import {
|
||||
setOrRemoveStyleProperty,
|
||||
} from '../utils/basic';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
||||
import {
|
||||
AdvancedCameraCardMediaLoadedEventTarget,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaUnloadedEvent,
|
||||
} from '../utils/media-info';
|
||||
|
||||
// The default minimum cell width: if the columns are not specified this value
|
||||
// is used to compute the number of columns, always trying to keep each cell as
|
||||
@@ -25,7 +19,7 @@ const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0;
|
||||
const MEDIA_GRID_HORIZONTAL_GUTTER_WIDTH = 1;
|
||||
|
||||
type GridID = string;
|
||||
type MediaGridChild = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget;
|
||||
type MediaGridChild = HTMLElement;
|
||||
type MediaGridContents = Map<GridID, MediaGridChild>;
|
||||
|
||||
export interface MediaGridSelected {
|
||||
@@ -51,7 +45,6 @@ export class MediaGridController {
|
||||
private _host: HTMLElement;
|
||||
|
||||
private _selected: GridID | null;
|
||||
private _mediaLoadedInfoMap: Map<GridID, MediaLoadedInfo> = new Map();
|
||||
private _gridContents: MediaGridContents = new Map();
|
||||
private _masonry: ExtendedMasonry | null = null;
|
||||
private _displayConfig: ViewDisplayConfig | null = null;
|
||||
@@ -111,7 +104,6 @@ export class MediaGridController {
|
||||
this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost);
|
||||
}
|
||||
|
||||
this._mediaLoadedInfoMap.clear();
|
||||
this._masonry?.destroy?.();
|
||||
this._masonry = null;
|
||||
|
||||
@@ -176,11 +168,6 @@ export class MediaGridController {
|
||||
this._selected = id;
|
||||
fireAdvancedCameraCardEvent(this._host, 'media-grid:selected', { selected: id });
|
||||
|
||||
const mediaLoadedInfo = this._mediaLoadedInfoMap.get(id);
|
||||
if (mediaLoadedInfo) {
|
||||
dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo);
|
||||
}
|
||||
|
||||
this._sortItemsInGrid();
|
||||
this._updateSelectedStylesOnElements();
|
||||
|
||||
@@ -189,7 +176,6 @@ export class MediaGridController {
|
||||
|
||||
public unselectAll() {
|
||||
if (this._selected !== null) {
|
||||
dispatchMediaUnloadedEvent(this._host);
|
||||
fireAdvancedCameraCardEvent(this._host, 'media-grid:unselected');
|
||||
}
|
||||
this._selected = null;
|
||||
@@ -223,14 +209,6 @@ export class MediaGridController {
|
||||
private _setGridContents(gridContents: MediaGridContents): void {
|
||||
this._gridContents = gridContents;
|
||||
|
||||
// Remove media loaded info objects that belong to objects no longer in the
|
||||
// grid.
|
||||
for (const key of this._mediaLoadedInfoMap.keys()) {
|
||||
if (!gridContents.has(key)) {
|
||||
this._mediaLoadedInfoMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._selected !== null && !this._gridContents.has(this._selected)) {
|
||||
this.unselectAll();
|
||||
}
|
||||
@@ -260,21 +238,6 @@ export class MediaGridController {
|
||||
this._setColumnSizeStyles();
|
||||
}
|
||||
|
||||
private _handleMediaLoadedInfoEvent = (ev: CustomEvent<MediaLoadedInfo>): void => {
|
||||
const eventPath = ev.composedPath();
|
||||
|
||||
for (const [id, element] of this._gridContents.entries()) {
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (eventPath.includes(element)) {
|
||||
this._mediaLoadedInfoMap.set(id, ev.detail);
|
||||
if (id !== this._selected) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _hostResizeHandler(): void {
|
||||
const dimensions = this._host.getBoundingClientRect();
|
||||
|
||||
@@ -303,22 +266,12 @@ export class MediaGridController {
|
||||
child.addEventListener('click', this._handleSelectGridCellEvent, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
child.addEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
this._handleMediaLoadedInfoEvent,
|
||||
);
|
||||
}
|
||||
|
||||
private _removeChildEventListeners(child: MediaGridChild): void {
|
||||
child.removeEventListener('click', this._handleSelectGridCellEvent, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
child.removeEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
this._handleMediaLoadedInfoEvent,
|
||||
);
|
||||
}
|
||||
|
||||
private _createMasonry(): void {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import { MediaLoadedInfo, MediaLoadedInfoEventDetail } from '../types';
|
||||
import { onAbort } from '../utils/abort-signal';
|
||||
|
||||
interface MediaLoadedInfoSinkConfig {
|
||||
// The currently active target the sink should expose via `get()` / `has()`
|
||||
// and notify the callback for. Polled on every host update so the sink can
|
||||
// detect selection changes (e.g. carousel slide change) without explicit
|
||||
// notification.
|
||||
getTargetID: () => string | null;
|
||||
|
||||
// Fires when the active info changes — i.e. when the active target's entry
|
||||
// transitions (load arrives, abort, or selection changes the active entry).
|
||||
// Loads for other targets are cached but do not fire the callback.
|
||||
callback?: (info: MediaLoadedInfo | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sink-side ReactiveController for the media-loaded lifecycle.
|
||||
*
|
||||
* Listens for `advanced-camera-card:media:loaded` events bubbling through the
|
||||
* host's subtree and caches them keyed by `MediaLoadedInfo.targetID`. The sink
|
||||
* exposes only the entry for the currently active target (via `getTargetID`).
|
||||
*
|
||||
* This per-target shape is what makes the sink work inside carousels that
|
||||
* render multiple slides concurrently: non-active slides may load and update
|
||||
* the cache, but they don't become the carousel's active media. When the
|
||||
* user selects a slide whose media has already loaded, the sink immediately
|
||||
* exposes the cached entry.
|
||||
*
|
||||
* Lifecycle asymmetry — `callback` fires on:
|
||||
* - the active target's load arrival,
|
||||
* - the active target's source aborting (with `null`), and
|
||||
* - selection changing to / from a target whose active info differs.
|
||||
*
|
||||
* It does NOT fire on the sink's own `hostDisconnected`: the host is detaching
|
||||
* and won't render, so notifying consumers of a "transition to null" is moot.
|
||||
* State is still cleared so a later reconnect doesn't observe stale info.
|
||||
*/
|
||||
export class MediaLoadedInfoSinkController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
private _config: MediaLoadedInfoSinkConfig;
|
||||
|
||||
// Per-target cache. Each entry holds the latest info dispatched under that
|
||||
// targetID; aborts clear only the matching entry, scoped by reference so a
|
||||
// stale abort can't blow away an entry that's since been overwritten.
|
||||
private _byTarget = new Map<string, MediaLoadedInfo>();
|
||||
|
||||
// The targetID whose info we last surfaced — drives `hostUpdated` change
|
||||
// detection. `_lastActiveInfo` records what the callback last saw, so we
|
||||
// don't fire it for no-op selection changes (e.g. selection changes but
|
||||
// both old and new are null/loaded with the same info reference).
|
||||
private _lastActiveTargetID: string | null = null;
|
||||
private _lastActiveInfo: MediaLoadedInfo | null = null;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & HTMLElement,
|
||||
config: MediaLoadedInfoSinkConfig,
|
||||
) {
|
||||
this._host = host;
|
||||
this._config = config;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._host.addEventListener('advanced-camera-card:media:loaded', this._handler);
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
// Detect selection changes — `getTargetID` is owned by the host and may
|
||||
// flip when its props change (carousel slide change, view change, etc.).
|
||||
const newID = this._config.getTargetID();
|
||||
if (newID !== this._lastActiveTargetID) {
|
||||
this._lastActiveTargetID = newID;
|
||||
this._notifyIfActiveChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._host.removeEventListener('advanced-camera-card:media:loaded', this._handler);
|
||||
|
||||
// Clear without firing `callback`/`requestUpdate`: see class doc.
|
||||
this._byTarget.clear();
|
||||
this._lastActiveTargetID = null;
|
||||
this._lastActiveInfo = null;
|
||||
}
|
||||
|
||||
public get(): MediaLoadedInfo | null {
|
||||
const id = this._config.getTargetID();
|
||||
return id ? this._byTarget.get(id) ?? null : null;
|
||||
}
|
||||
|
||||
public has(): boolean {
|
||||
return !!this.get();
|
||||
}
|
||||
|
||||
private _handler = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
|
||||
const id = ev.detail.info.targetID;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
this._byTarget.set(id, ev.detail.info);
|
||||
if (id === this._config.getTargetID()) {
|
||||
this._notifyIfActiveChanged();
|
||||
}
|
||||
|
||||
onAbort(ev.detail.signal, () => {
|
||||
// Reference check: a newer load for the same target has overwritten
|
||||
// this entry, so this stale abort is a no-op.
|
||||
if (this._byTarget.get(id) !== ev.detail.info) {
|
||||
return;
|
||||
}
|
||||
this._byTarget.delete(id);
|
||||
if (id === this._config.getTargetID()) {
|
||||
this._notifyIfActiveChanged();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private _notifyIfActiveChanged(): void {
|
||||
const active = this.get();
|
||||
if (active === this._lastActiveInfo) {
|
||||
return;
|
||||
}
|
||||
this._lastActiveInfo = active;
|
||||
this._config.callback?.(active);
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
import {
|
||||
MediaLoadedInfo,
|
||||
MediaLoadedInfoEventDetail,
|
||||
UntargetedMediaLoadedInfo,
|
||||
} from '../types';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
||||
|
||||
// Structural equality on `MediaLoadedInfo`, except `mediaPlayerController` is
|
||||
// reference-compared. Implementations hold a `LitElement` host whose own
|
||||
// properties include all `@property` bindings (`hass`, `cameraManager`, etc.)
|
||||
// plus Lit's transient render bookkeeping; deep-walking those is expensive,
|
||||
// reaches across unrelated app state, and produces false negatives from fields
|
||||
// that flip between renders. Reference identity is the right granularity: same
|
||||
// controller instance = same player.
|
||||
const isEquivalentInfo = (a: MediaLoadedInfo | null, b: MediaLoadedInfo): boolean =>
|
||||
!!a &&
|
||||
a.mediaPlayerController === b.mediaPlayerController &&
|
||||
isEqual(omit(a, 'mediaPlayerController'), omit(b, 'mediaPlayerController'));
|
||||
|
||||
interface MediaLoadedInfoSourceConfig {
|
||||
getTargetID: () => string | null;
|
||||
}
|
||||
|
||||
type TargetedMediaLoadedInfo = MediaLoadedInfo & { targetID: string };
|
||||
|
||||
/**
|
||||
* Source-side ReactiveController for the media-loaded lifecycle.
|
||||
*
|
||||
* On `set(info)` dispatches a bubbling `advanced-camera-card:media:loaded`
|
||||
* event with `{ info, signal }`. Listeners along the bubble path register
|
||||
* cleanup via the signal. The signal fires on host disconnect.
|
||||
*
|
||||
* Reconnect path: Lit may reuse the host across disconnect/reconnect. We
|
||||
* re-dispatch from `_lastSet` on `hostConnected` so the registration survives
|
||||
* without needing the underlying media to re-fire a load (e.g., HaHlsPlayer
|
||||
* keeps the same `<video>`).
|
||||
*
|
||||
* Aggregator parents (e.g., `ha-camera-stream`) sit on the bubble path; they
|
||||
* can `stopPropagation` on inner-leaf events and dispatch their own via their
|
||||
* own source controller, so consumers above the boundary only see the
|
||||
* aggregate.
|
||||
*/
|
||||
export class MediaLoadedInfoSourceController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
private _config: MediaLoadedInfoSourceConfig;
|
||||
|
||||
// AbortController for the active dispatch; aborting it fires the cleanup
|
||||
// callbacks consumers registered against the event's `signal`. Non-null iff
|
||||
// a `media:loaded` is currently in flight, in which case `_lastSet` is the
|
||||
// info that was dispatched.
|
||||
private _abort: AbortController | null = null;
|
||||
|
||||
// Survives disconnect so we can re-dispatch on reconnect. Only ever holds
|
||||
// info validated by `set` — i.e., always has a targetID.
|
||||
private _lastSet: TargetedMediaLoadedInfo | null = null;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & HTMLElement,
|
||||
config: MediaLoadedInfoSourceConfig,
|
||||
) {
|
||||
this._host = host;
|
||||
this._config = config;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
// Two early-returns:
|
||||
// - `!_lastSet`: nothing to replay — either the host has never seen a
|
||||
// media load or the cache was discarded as stale on a prior reconnect
|
||||
// (see below).
|
||||
// - `_abort` non-null: a dispatch is already live, meaning we're already
|
||||
// registered with consumers. Re-firing would orphan the prior
|
||||
// `AbortController` (no one would ever abort it) and emit a duplicate
|
||||
// event. Defensive against `hostConnected` firing without an intervening
|
||||
// `hostDisconnected`.
|
||||
if (!this._lastSet || this._abort) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revalidate against the current targetID — the host's property may have
|
||||
// flipped while we were disconnected. Replaying the cached info under a
|
||||
// stale targetID would misregister with the manager.
|
||||
if (this._lastSet.targetID === this._config.getTargetID()) {
|
||||
this._dispatchLoad(this._lastSet);
|
||||
} else {
|
||||
this._lastSet = null;
|
||||
}
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._unload();
|
||||
}
|
||||
|
||||
public set(info: UntargetedMediaLoadedInfo): void {
|
||||
const targetID = this._config.getTargetID();
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the host's targetID changed since the prior dispatch, abort the
|
||||
// prior dispatch's signal *before* registering the new one. Why:
|
||||
// - When we dispatched the prior load, consumers (the manager, sinks,
|
||||
// etc.) attached `onAbort` cleanup callbacks to that signal. The
|
||||
// manager's callback is `_clearTarget(<old targetID>, owner)`.
|
||||
// - If we skip the abort and proceed to `_dispatchLoad(new)`, we
|
||||
// overwrite `_abort` with a fresh `AbortController`. The old
|
||||
// controller becomes unreachable from us, but its signal is still
|
||||
// held by consumers' listeners. We never call `.abort()` on it, so
|
||||
// those cleanup callbacks never fire. The manager's
|
||||
// `_active[<old targetID>]` entry zombies until the next time the
|
||||
// host disconnects (which aborts only the *new* controller).
|
||||
// - Aborting first triggers the old cleanup synchronously: the
|
||||
// manager clears its old entry, sinks drop their cached info, and
|
||||
// we then register the new entry cleanly.
|
||||
//
|
||||
// No current consumer rebinds targetID in-place (the substream layer
|
||||
// keeps it stable above the playback chain), but the abstraction must
|
||||
// remain safe in that general case.
|
||||
if (this._lastSet && this._lastSet.targetID !== targetID) {
|
||||
this._unload();
|
||||
}
|
||||
const validated: TargetedMediaLoadedInfo = { ...info, targetID };
|
||||
if (isEquivalentInfo(this._lastSet, validated)) {
|
||||
return;
|
||||
}
|
||||
this._lastSet = validated;
|
||||
this._dispatchLoad(validated);
|
||||
}
|
||||
|
||||
private _unload(): void {
|
||||
this._abort?.abort();
|
||||
this._abort = null;
|
||||
}
|
||||
|
||||
private _dispatchLoad(info: TargetedMediaLoadedInfo): void {
|
||||
this._abort = new AbortController();
|
||||
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
|
||||
info,
|
||||
signal: this._abort.signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
<!-- -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user