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`. |
|
| `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. |
|
| `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`
|
## `microphone`
|
||||||
|
|
||||||
Matches based on microphone state.
|
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 { log } from '../utils/debug';
|
||||||
import { isValidMediaLoadedInfo } from '../utils/media-info';
|
import { isValidMediaLoadedInfo } from '../utils/media-info';
|
||||||
import { CardMediaLoadedAPI } from './types';
|
import { CardMediaLoadedAPI } from './types';
|
||||||
|
|
||||||
|
interface ActiveEntry {
|
||||||
|
info: MediaLoadedInfo;
|
||||||
|
owner: MediaLoadedInfoOwner;
|
||||||
|
}
|
||||||
|
|
||||||
export class MediaLoadedInfoManager {
|
export class MediaLoadedInfoManager {
|
||||||
private _api: CardMediaLoadedAPI;
|
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) {
|
constructor(api: CardMediaLoadedAPI) {
|
||||||
this._api = api;
|
this._api = api;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
public initialize(): void {
|
||||||
this.clear();
|
this._active.clear();
|
||||||
}
|
this._lastKnown.clear();
|
||||||
|
this._selected = null;
|
||||||
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._api.getConditionStateManager().setState({ mediaLoadedInfo: 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 {
|
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._view = view;
|
||||||
this._epoch = this._createEpoch(oldView);
|
this._epoch = this._createEpoch(oldView);
|
||||||
|
|
||||||
if (this.hasMajorMediaChange(oldView)) {
|
this._api
|
||||||
this._api.getMediaLoadedInfoManager().clear();
|
.getMediaLoadedInfoManager()
|
||||||
}
|
.setSelected(view ? getViewTargetID(view) : null);
|
||||||
|
|
||||||
if (oldView?.view !== view?.view) {
|
if (oldView?.view !== view?.view) {
|
||||||
this._api.getCardElementManager().scrollReset();
|
this._api.getCardElementManager().scrollReset();
|
||||||
|
|||||||
+4
-6
@@ -33,7 +33,7 @@ import { REPO_URL } from './const.js';
|
|||||||
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
import cardStyle from './scss/card.scss';
|
import cardStyle from './scss/card.scss';
|
||||||
import { MediaLoadedInfo } from './types.js';
|
import { MediaLoadedInfoEventDetail } from './types.js';
|
||||||
import { hasAction } from './utils/action.js';
|
import { hasAction } from './utils/action.js';
|
||||||
import { getReleaseVersion } from './utils/diagnostics';
|
import { getReleaseVersion } from './utils/diagnostics';
|
||||||
|
|
||||||
@@ -375,17 +375,15 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
hasDoubleClick: hasAction(actions.double_tap_action),
|
hasDoubleClick: hasAction(actions.double_tap_action),
|
||||||
})}
|
})}
|
||||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
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>) =>
|
@advanced-camera-card:issue:notify=${(ev: CustomEvent<IssueKey>) =>
|
||||||
this._controller.getIssueManager().showNotification(ev.detail)}
|
this._controller.getIssueManager().showNotification(ev.detail)}
|
||||||
@advanced-camera-card:issue:trigger=${({
|
@advanced-camera-card:issue:trigger=${({
|
||||||
detail: { key, ...context },
|
detail: { key, ...context },
|
||||||
}: CustomEvent<IssueTriggerEventData>) =>
|
}: CustomEvent<IssueTriggerEventData>) =>
|
||||||
this._controller.getIssueManager().trigger(key, context)}
|
this._controller.getIssueManager().trigger(key, context)}
|
||||||
|
@advanced-camera-card:media:loaded=${(
|
||||||
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
|
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
|
||||||
@advanced-camera-card:media:volumechange=${
|
@advanced-camera-card:media:volumechange=${
|
||||||
() => this.requestUpdate() /* Refresh mute menu button */
|
() => 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,
|
AutoUnmuteCondition,
|
||||||
} from '../config/schema/common/media-actions.js';
|
} from '../config/schema/common/media-actions.js';
|
||||||
import { MediaPlayerElement } from '../types.js';
|
import { MediaPlayerElement } from '../types.js';
|
||||||
import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info.js';
|
|
||||||
import { Timer } from '../utils/timer.js';
|
import { Timer } from '../utils/timer.js';
|
||||||
|
|
||||||
export interface MediaActionsControllerOptions {
|
export interface MediaActionsControllerOptions {
|
||||||
@@ -21,7 +20,7 @@ export interface MediaActionsControllerOptions {
|
|||||||
microphoneMuteSeconds?: number;
|
microphoneMuteSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderRoot = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget;
|
type RenderRoot = HTMLElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General note: Always unmute before playing, since Chrome may pause a piece of
|
* General note: Always unmute before playing, since Chrome may pause a piece of
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { CameraDimensionsConfig } from '../config/schema/cameras';
|
import { CameraDimensionsConfig } from '../config/schema/cameras';
|
||||||
import { MediaLoadedInfo } from '../types';
|
import { MediaLoadedInfoEventDetail } from '../types';
|
||||||
import {
|
import {
|
||||||
aspectRatioToString,
|
aspectRatioToString,
|
||||||
setOrRemoveAttribute,
|
setOrRemoveAttribute,
|
||||||
setOrRemoveStyleProperty,
|
setOrRemoveStyleProperty,
|
||||||
} from '../utils/basic';
|
} from '../utils/basic';
|
||||||
import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info';
|
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout';
|
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout';
|
||||||
|
|
||||||
const ROTATED_ATTRIBUTE = 'rotated';
|
const ROTATED_ATTRIBUTE = 'rotated';
|
||||||
@@ -33,9 +32,7 @@ export class MediaDimensionsContainerController implements ReactiveController {
|
|||||||
|
|
||||||
private _dimensionsConfig: CameraDimensionsConfig | null = null;
|
private _dimensionsConfig: CameraDimensionsConfig | null = null;
|
||||||
|
|
||||||
private _innerContainer:
|
private _innerContainer: HTMLElement | null = null;
|
||||||
| (HTMLElement & AdvancedCameraCardMediaLoadedEventTarget)
|
|
||||||
| null = null;
|
|
||||||
private _outerContainer: HTMLElement | null = null;
|
private _outerContainer: HTMLElement | null = null;
|
||||||
|
|
||||||
public resize = debounce(this._resize.bind(this), 100, { trailing: true });
|
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
|
// Only resize if the media dimensions have changed (otherwise the loading
|
||||||
// image whilst waiting for the stream, will trigger aresize every second).
|
// image whilst waiting for the stream, will trigger aresize every second).
|
||||||
if (
|
if (
|
||||||
this._mediaDimensions?.width === ev.detail.width &&
|
this._mediaDimensions?.width === info.width &&
|
||||||
this._mediaDimensions.height === ev.detail.height
|
this._mediaDimensions.height === info.height
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._mediaDimensions = {
|
this._mediaDimensions = {
|
||||||
width: ev.detail.width,
|
width: info.width,
|
||||||
height: ev.detail.height,
|
height: info.height,
|
||||||
};
|
};
|
||||||
this.resize();
|
this.resize();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { isEqual, throttle } from 'lodash-es';
|
import { isEqual, throttle } from 'lodash-es';
|
||||||
import Masonry from 'masonry-layout';
|
import Masonry from 'masonry-layout';
|
||||||
import { ViewDisplayConfig } from '../config/schema/common/display';
|
import { ViewDisplayConfig } from '../config/schema/common/display';
|
||||||
import { MediaLoadedInfo } from '../types';
|
|
||||||
import {
|
import {
|
||||||
forceReflow,
|
forceReflow,
|
||||||
getChildrenFromElement,
|
getChildrenFromElement,
|
||||||
@@ -9,11 +8,6 @@ import {
|
|||||||
setOrRemoveStyleProperty,
|
setOrRemoveStyleProperty,
|
||||||
} from '../utils/basic';
|
} from '../utils/basic';
|
||||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
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
|
// 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
|
// 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;
|
const MEDIA_GRID_HORIZONTAL_GUTTER_WIDTH = 1;
|
||||||
|
|
||||||
type GridID = string;
|
type GridID = string;
|
||||||
type MediaGridChild = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget;
|
type MediaGridChild = HTMLElement;
|
||||||
type MediaGridContents = Map<GridID, MediaGridChild>;
|
type MediaGridContents = Map<GridID, MediaGridChild>;
|
||||||
|
|
||||||
export interface MediaGridSelected {
|
export interface MediaGridSelected {
|
||||||
@@ -51,7 +45,6 @@ export class MediaGridController {
|
|||||||
private _host: HTMLElement;
|
private _host: HTMLElement;
|
||||||
|
|
||||||
private _selected: GridID | null;
|
private _selected: GridID | null;
|
||||||
private _mediaLoadedInfoMap: Map<GridID, MediaLoadedInfo> = new Map();
|
|
||||||
private _gridContents: MediaGridContents = new Map();
|
private _gridContents: MediaGridContents = new Map();
|
||||||
private _masonry: ExtendedMasonry | null = null;
|
private _masonry: ExtendedMasonry | null = null;
|
||||||
private _displayConfig: ViewDisplayConfig | null = null;
|
private _displayConfig: ViewDisplayConfig | null = null;
|
||||||
@@ -111,7 +104,6 @@ export class MediaGridController {
|
|||||||
this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost);
|
this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._mediaLoadedInfoMap.clear();
|
|
||||||
this._masonry?.destroy?.();
|
this._masonry?.destroy?.();
|
||||||
this._masonry = null;
|
this._masonry = null;
|
||||||
|
|
||||||
@@ -176,11 +168,6 @@ export class MediaGridController {
|
|||||||
this._selected = id;
|
this._selected = id;
|
||||||
fireAdvancedCameraCardEvent(this._host, 'media-grid:selected', { 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._sortItemsInGrid();
|
||||||
this._updateSelectedStylesOnElements();
|
this._updateSelectedStylesOnElements();
|
||||||
|
|
||||||
@@ -189,7 +176,6 @@ export class MediaGridController {
|
|||||||
|
|
||||||
public unselectAll() {
|
public unselectAll() {
|
||||||
if (this._selected !== null) {
|
if (this._selected !== null) {
|
||||||
dispatchMediaUnloadedEvent(this._host);
|
|
||||||
fireAdvancedCameraCardEvent(this._host, 'media-grid:unselected');
|
fireAdvancedCameraCardEvent(this._host, 'media-grid:unselected');
|
||||||
}
|
}
|
||||||
this._selected = null;
|
this._selected = null;
|
||||||
@@ -223,14 +209,6 @@ export class MediaGridController {
|
|||||||
private _setGridContents(gridContents: MediaGridContents): void {
|
private _setGridContents(gridContents: MediaGridContents): void {
|
||||||
this._gridContents = gridContents;
|
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)) {
|
if (this._selected !== null && !this._gridContents.has(this._selected)) {
|
||||||
this.unselectAll();
|
this.unselectAll();
|
||||||
}
|
}
|
||||||
@@ -260,21 +238,6 @@ export class MediaGridController {
|
|||||||
this._setColumnSizeStyles();
|
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 {
|
private _hostResizeHandler(): void {
|
||||||
const dimensions = this._host.getBoundingClientRect();
|
const dimensions = this._host.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -303,22 +266,12 @@ export class MediaGridController {
|
|||||||
child.addEventListener('click', this._handleSelectGridCellEvent, {
|
child.addEventListener('click', this._handleSelectGridCellEvent, {
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
child.addEventListener(
|
|
||||||
'advanced-camera-card:media:loaded',
|
|
||||||
this._handleMediaLoadedInfoEvent,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _removeChildEventListeners(child: MediaGridChild): void {
|
private _removeChildEventListeners(child: MediaGridChild): void {
|
||||||
child.removeEventListener('click', this._handleSelectGridCellEvent, {
|
child.removeEventListener('click', this._handleSelectGridCellEvent, {
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
child.removeEventListener(
|
|
||||||
'advanced-camera-card:media:loaded',
|
|
||||||
this._handleMediaLoadedInfoEvent,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createMasonry(): void {
|
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 { customElement, property } from 'lit/decorators.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.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 { ImageMediaPlayerController } from '../components-lib/media-player/image';
|
||||||
import imagePlayerStyle from '../scss/image-player.scss';
|
import imagePlayerStyle from '../scss/image-player.scss';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
MediaPlayerElement,
|
MediaPlayerElement,
|
||||||
MediaTechnology,
|
MediaTechnology,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { dispatchMediaLoadedEvent } from '../utils/media-info';
|
import { createMediaLoadedInfo } from '../utils/media-info';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple media player to wrap a single static image.
|
* A simple media player to wrap a single static image.
|
||||||
@@ -20,6 +21,9 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
|
|||||||
@property()
|
@property()
|
||||||
public url?: string;
|
public url?: string;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
public targetID?: string;
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
public technology?: MediaTechnology;
|
public technology?: MediaTechnology;
|
||||||
|
|
||||||
@@ -29,6 +33,10 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
|
|||||||
() => this._refImage.value ?? null,
|
() => this._refImage.value ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
@@ -38,12 +46,15 @@ export class AdvancedCameraCardImagePlayer extends LitElement implements MediaPl
|
|||||||
${ref(this._refImage)}
|
${ref(this._refImage)}
|
||||||
src="${ifDefined(this.url)}"
|
src="${ifDefined(this.url)}"
|
||||||
@load=${(ev: Event) => {
|
@load=${(ev: Event) => {
|
||||||
dispatchMediaLoadedEvent(this, ev, {
|
const info = createMediaLoadedInfo(ev, {
|
||||||
...(this._mediaPlayerController && {
|
...(this._mediaPlayerController && {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
}),
|
}),
|
||||||
technology: [this.technology ?? ('jpg' as const)],
|
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 { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { live } from 'lit/directives/live.js';
|
import { live } from 'lit/directives/live.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.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 { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
|
||||||
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
||||||
import { CachedValueController } from '../components-lib/cached-value-controller.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 { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
||||||
import { dataToContext } from '../components-lib/notification/data-to-context.js';
|
import { dataToContext } from '../components-lib/notification/data-to-context.js';
|
||||||
import { SignedURLController } from '../components-lib/signed-url-controller.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 defaultImage from '../images/iris-screensaver.jpg';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss';
|
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 { contentsChanged } from '../utils/basic.js';
|
||||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||||
import {
|
import {
|
||||||
createMediaLoadedInfo,
|
createMediaLoadedInfo,
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
} from '../utils/media-info.js';
|
} from '../utils/media-info.js';
|
||||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js';
|
|
||||||
import { View } from '../view/view.js';
|
import { View } from '../view/view.js';
|
||||||
import { renderNotificationBlock } from './notification/block.js';
|
import { renderNotificationBlock } from './notification/block.js';
|
||||||
|
|
||||||
@@ -90,6 +88,9 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public targetID?: string;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public proxyConfig?: EnabledProxyConfig;
|
public proxyConfig?: EnabledProxyConfig;
|
||||||
|
|
||||||
@@ -143,14 +144,16 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
|
|
||||||
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
||||||
|
|
||||||
private _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
|
||||||
|
|
||||||
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
|
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
|
||||||
this,
|
this,
|
||||||
() => this._refImage.value ?? null,
|
() => this._refImage.value ?? null,
|
||||||
() => this._cachedValueController,
|
() => this._cachedValueController,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
@@ -452,11 +455,8 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
supportsPause: !!this._getEffectiveRefreshSeconds(),
|
supportsPause: !!this._getEffectiveRefreshSeconds(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Avoid the media being reported as repeatedly loading unless the
|
if (mediaLoadedInfo) {
|
||||||
// media info changes.
|
this._mediaLoadedInfoSourceController.set(mediaLoadedInfo);
|
||||||
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
|
|
||||||
this._mediaLoadedInfo = mediaLoadedInfo;
|
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@error=${() => {
|
@error=${() => {
|
||||||
@@ -469,10 +469,16 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
} else if (mode === 'url') {
|
} else if (mode === 'url') {
|
||||||
this._imageLoadError = true;
|
this._imageLoadError = true;
|
||||||
}
|
}
|
||||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
|
if (this.targetID) {
|
||||||
|
fireAdvancedCameraCardEvent<IssueTriggerEventData>(
|
||||||
|
this,
|
||||||
|
'issue:trigger',
|
||||||
|
{
|
||||||
key: 'media_load',
|
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}
|
.view=${view}
|
||||||
.imageConfig=${this.imageConfig}
|
.imageConfig=${this.imageConfig}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
|
.targetID=${IMAGE_VIEW_TARGET_ID_SENTINEL}
|
||||||
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ??
|
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ??
|
||||||
undefined}
|
undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
|
||||||
import { keyed } from 'lit/directives/keyed.js';
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.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 { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||||
import { MediaHeightController } from '../../components-lib/media-height-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 { PTZDragController } from '../../components-lib/ptz/drag-controller.js';
|
||||||
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
||||||
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.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 liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.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 { getStreamCameraID } from '../../utils/substream.js';
|
||||||
import { getTextDirection } from '../../utils/text-direction.js';
|
import { getTextDirection } from '../../utils/text-direction.js';
|
||||||
import { View } from '../../view/view.js';
|
import { View } from '../../view/view.js';
|
||||||
import '../carousel';
|
import '../carousel';
|
||||||
import { EmblaCarouselPlugins } from '../carousel.js';
|
|
||||||
import '../next-prev-control.js';
|
import '../next-prev-control.js';
|
||||||
import '../ptz.js';
|
import '../ptz.js';
|
||||||
import './provider.js';
|
import './provider.js';
|
||||||
@@ -82,8 +80,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
|
|
||||||
private _ptzDragController = new PTZDragController(this);
|
private _ptzDragController = new PTZDragController(this);
|
||||||
|
|
||||||
@state()
|
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
|
||||||
private _mediaHasLoaded = false;
|
getTargetID: () =>
|
||||||
|
this.viewFilterCameraID ??
|
||||||
|
this.viewManagerEpoch?.manager.getView()?.camera ??
|
||||||
|
null,
|
||||||
|
callback: () => this._mediaHeightController.recalculate(),
|
||||||
|
});
|
||||||
|
|
||||||
public connectedCallback(): void {
|
public connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -172,23 +175,18 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getPlugins(): EmblaCarouselPlugins {
|
|
||||||
return [AutoMediaLoadedInfo()];
|
|
||||||
}
|
|
||||||
|
|
||||||
private _getSlides(): TemplateResult[] {
|
private _getSlides(): TemplateResult[] {
|
||||||
if (!this.cameraManager) {
|
if (!this.cameraManager) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const view = this.viewManagerEpoch?.manager.getView();
|
|
||||||
const cameraIDs = this.viewFilterCameraID
|
const cameraIDs = this.viewFilterCameraID
|
||||||
? new Set([this.viewFilterCameraID])
|
? new Set([this.viewFilterCameraID])
|
||||||
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||||
|
|
||||||
const slides: TemplateResult[] = [];
|
const slides: TemplateResult[] = [];
|
||||||
for (const cameraID of cameraIDs ?? []) {
|
for (const cameraID of cameraIDs ?? []) {
|
||||||
const slide = this._renderLive(this._getSubstreamCameraID(cameraID, view));
|
const slide = this._renderLive(cameraID);
|
||||||
if (slide) {
|
if (slide) {
|
||||||
slides.push(slide);
|
slides.push(slide);
|
||||||
}
|
}
|
||||||
@@ -214,13 +212,20 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _renderLive(cameraID: string): TemplateResult | void {
|
private _renderLive(cameraID: string): TemplateResult | void {
|
||||||
const camera = this.cameraManager?.getStore().getCamera(cameraID);
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
if (!this.liveConfig || !this.hass || !this.cameraManager || !camera) {
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
|
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
|
||||||
const view = this.viewManagerEpoch?.manager.getView();
|
|
||||||
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
|
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
@@ -231,11 +236,8 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
.microphoneState=${view?.camera === cameraID
|
.microphoneState=${view?.camera === cameraID
|
||||||
? this.microphoneState
|
? this.microphoneState
|
||||||
: undefined}
|
: undefined}
|
||||||
.camera=${camera}
|
.camera=${resolvedCamera}
|
||||||
.cameraEndpoints=${guard(
|
.targetID=${cameraID}
|
||||||
[this.cameraManager, cameraID],
|
|
||||||
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
|
|
||||||
)}
|
|
||||||
.label=${cameraMetadata?.title ?? ''}
|
.label=${cameraMetadata?.title ?? ''}
|
||||||
.liveConfig=${this.liveConfig}
|
.liveConfig=${this.liveConfig}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -348,7 +350,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID);
|
const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID);
|
||||||
|
|
||||||
const forcePTZVisibility =
|
const forcePTZVisibility =
|
||||||
!this._mediaHasLoaded ||
|
!this._mediaLoadedInfoSinkController.has() ||
|
||||||
(!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) ||
|
(!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) ||
|
||||||
view.context?.ptzControls?.enabled === false
|
view.context?.ptzControls?.enabled === false
|
||||||
? false
|
? false
|
||||||
@@ -357,30 +359,15 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
const dragEnabled =
|
const dragEnabled =
|
||||||
hasMultipleCameras && this.liveConfig?.draggable && !gesturesPTZActive;
|
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`
|
return html`
|
||||||
<advanced-camera-card-carousel
|
<advanced-camera-card-carousel
|
||||||
${ref(this._refCarousel)}
|
${ref(this._refCarousel)}
|
||||||
.loop=${hasMultipleCameras}
|
.loop=${hasMultipleCameras}
|
||||||
.dragEnabled=${dragEnabled}
|
.dragEnabled=${dragEnabled}
|
||||||
.plugins=${guard(
|
|
||||||
[this.cameraManager, this.liveConfig],
|
|
||||||
this._getPlugins.bind(this),
|
|
||||||
)}
|
|
||||||
.selected=${this._getSelectedCameraIndex()}
|
.selected=${this._getSelectedCameraIndex()}
|
||||||
.wheelScrolling=${this.liveConfig?.controls.wheel}
|
.wheelScrolling=${this.liveConfig?.controls.wheel}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@advanced-camera-card:carousel:select=${this._setViewHandler.bind(this)}
|
@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)}
|
${this._renderNextPrevious('left', neighbors)}
|
||||||
<!-- -->
|
<!-- -->
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import { MicrophoneState } from '../../card-controller/types.js';
|
import { MicrophoneState } from '../../card-controller/types.js';
|
||||||
import { ViewManagerEpoch } from '../../card-controller/view/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 { LiveConfig } from '../../config/schema/live.js';
|
||||||
import { CardWideConfig } from '../../config/schema/types.js';
|
import { CardWideConfig } from '../../config/schema/types.js';
|
||||||
import { HomeAssistant } from '../../ha/types.js';
|
import { HomeAssistant } from '../../ha/types.js';
|
||||||
@@ -34,8 +34,6 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public triggeredCameraIDs?: Set<string>;
|
public triggeredCameraIDs?: Set<string>;
|
||||||
|
|
||||||
private _controller = new LiveController(this);
|
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.cameraManager) {
|
if (!this.hass || !this.cameraManager) {
|
||||||
return;
|
return;
|
||||||
@@ -46,7 +44,6 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.liveConfig=${this.liveConfig}
|
.liveConfig=${this.liveConfig}
|
||||||
.inBackground=${this._controller.isInBackground()}
|
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.microphoneState=${this.microphoneState}
|
.microphoneState=${this.microphoneState}
|
||||||
|
|||||||
@@ -11,25 +11,19 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { Camera } from '../../camera-manager/camera.js';
|
import { Camera } from '../../camera-manager/camera.js';
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
|
||||||
import { MicrophoneState } from '../../card-controller/types.js';
|
import { MicrophoneState } from '../../card-controller/types.js';
|
||||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.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 { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
||||||
import { LiveConfig } from '../../config/schema/live.js';
|
import { LiveConfig } from '../../config/schema/live.js';
|
||||||
import { CardWideConfig } from '../../config/schema/types.js';
|
import { CardWideConfig } from '../../config/schema/types.js';
|
||||||
import { HomeAssistant } from '../../ha/types.js';
|
import { HomeAssistant } from '../../ha/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||||
import {
|
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
||||||
MediaLoadedInfo,
|
|
||||||
MediaPlayer,
|
|
||||||
MediaPlayerController,
|
|
||||||
MediaPlayerElement,
|
|
||||||
} from '../../types.js';
|
|
||||||
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event.js';
|
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event.js';
|
||||||
import { getResolvedLiveProvider } from '../../utils/live-provider.js';
|
import { getResolvedLiveProvider } from '../../utils/live-provider.js';
|
||||||
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
|
|
||||||
import '../icon.js';
|
import '../icon.js';
|
||||||
import { renderNotificationBlockFromText } from '../notification/block.js';
|
import { renderNotificationBlockFromText } from '../notification/block.js';
|
||||||
import './../media-dimensions-container';
|
import './../media-dimensions-container';
|
||||||
@@ -42,8 +36,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public camera?: Camera;
|
public camera?: Camera;
|
||||||
|
|
||||||
|
// The BASE camera ID (camera property may be a substream)
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraEndpoints?: CameraEndpoints;
|
public targetID?: string;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public liveConfig?: LiveConfig;
|
public liveConfig?: LiveConfig;
|
||||||
@@ -64,8 +59,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public zoom = true;
|
public zoom = true;
|
||||||
|
|
||||||
@state()
|
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
|
||||||
private _isVideoMediaLoaded = false;
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private _zoomed = false;
|
private _zoomed = false;
|
||||||
@@ -95,16 +91,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
// the background. These calls fail without waiting for loading here.
|
// the background. These calls fail without waiting for loading here.
|
||||||
private _importPromises: Promise<unknown>[] = [];
|
private _importPromises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this._lazyLoadController.addListener((loaded: boolean) => {
|
|
||||||
if (!loaded) {
|
|
||||||
this._isVideoMediaLoaded = false;
|
|
||||||
dispatchMediaUnloadedEvent(this);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
|
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
|
||||||
@@ -117,7 +103,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
*/
|
*/
|
||||||
private _shouldShowImageDuringLoading(): boolean {
|
private _shouldShowImageDuringLoading(): boolean {
|
||||||
return (
|
return (
|
||||||
!this._isVideoMediaLoaded &&
|
!this._mediaLoadedInfoSinkController.has() &&
|
||||||
!!this.camera?.getConfig()?.camera_entity &&
|
!!this.camera?.getConfig()?.camera_entity &&
|
||||||
!!this.hass &&
|
!!this.hass &&
|
||||||
!!this.liveConfig?.show_image_during_load &&
|
!!this.liveConfig?.show_image_during_load &&
|
||||||
@@ -126,25 +112,18 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
}
|
}
|
||||||
|
|
||||||
public disconnectedCallback(): void {
|
public disconnectedCallback(): void {
|
||||||
this._isVideoMediaLoaded = false;
|
|
||||||
this._entityHasBeenAvailable = false;
|
this._entityHasBeenAvailable = false;
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _videoMediaShowHandler(): void {
|
|
||||||
this._isVideoMediaLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _providerErrorHandler(ev: Event): void {
|
private _providerErrorHandler(ev: Event): void {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
this._hasProviderError = true;
|
this._hasProviderError = true;
|
||||||
|
|
||||||
// this.camera is already substream-aware (resolved by the carousel layer).
|
if (this.targetID) {
|
||||||
const targetID = this.camera?.getID();
|
|
||||||
if (targetID) {
|
|
||||||
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
|
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
|
||||||
key: 'media_load' as const,
|
key: 'media_load' as const,
|
||||||
targetID,
|
targetID: this.targetID,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +149,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('camera')) {
|
if (changedProps.has('camera')) {
|
||||||
this._isVideoMediaLoaded = false;
|
|
||||||
this._hasProviderError = false;
|
this._hasProviderError = false;
|
||||||
this._entityHasBeenAvailable = false;
|
this._entityHasBeenAvailable = false;
|
||||||
|
|
||||||
@@ -212,13 +190,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
const config = this.camera?.getConfig();
|
const config = this.camera?.getConfig();
|
||||||
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
|
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
|
||||||
.dimensionsConfig=${config?.dimensions}
|
.dimensionsConfig=${config?.dimensions}
|
||||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
|
||||||
if (ev.detail.placeholder) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
} else {
|
|
||||||
this._videoMediaShowHandler();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
${template}
|
${template}
|
||||||
</advanced-camera-card-media-dimensions-container>`;
|
</advanced-camera-card-media-dimensions-container>`;
|
||||||
@@ -289,7 +260,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
cameraConfig.always_error_if_entity_unavailable
|
cameraConfig.always_error_if_entity_unavailable
|
||||||
) {
|
) {
|
||||||
dispatchLiveErrorEvent(this);
|
dispatchLiveErrorEvent(this);
|
||||||
dispatchMediaUnloadedEvent(this);
|
|
||||||
return renderNotificationBlockFromText(
|
return renderNotificationBlockFromText(
|
||||||
`${localize('error.live_camera_unavailable')}${
|
`${localize('error.live_camera_unavailable')}${
|
||||||
this.label ? `: ${this.label}` : ''
|
this.label ? `: ${this.label}` : ''
|
||||||
@@ -303,7 +273,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showImageDuringLoading = this._shouldShowImageDuringLoading();
|
const showImageDuringLoading = this._shouldShowImageDuringLoading();
|
||||||
const showLoadingIcon = !this._isVideoMediaLoaded;
|
const showLoadingIcon = !this._mediaLoadedInfoSinkController.has();
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
hidden: showImageDuringLoading,
|
hidden: showImageDuringLoading,
|
||||||
@@ -314,8 +284,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
? html` <advanced-camera-card-live-image
|
? html` <advanced-camera-card-live-image
|
||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.camera=${this.camera}
|
||||||
.proxyConfig=${this.camera.getLiveProxyConfig()}
|
.targetID=${this.targetID}
|
||||||
class=${classMap({
|
class=${classMap({
|
||||||
...classes,
|
...classes,
|
||||||
// The image provider is providing the temporary loading image,
|
// 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) =>
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
||||||
this._providerErrorHandler(ev)}
|
this._providerErrorHandler(ev)}
|
||||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
@advanced-camera-card:media:loaded=${(ev: Event) => {
|
||||||
ev.detail.placeholder = provider !== 'image';
|
// 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>`
|
</advanced-camera-card-live-image>`
|
||||||
@@ -335,7 +312,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
class=${classMap(classes)}
|
class=${classMap(classes)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.camera=${this.camera}
|
||||||
|
.targetID=${this.targetID}
|
||||||
?controls=${this._getEffectiveBuiltinControls()}
|
?controls=${this._getEffectiveBuiltinControls()}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
||||||
this._providerErrorHandler(ev)}
|
this._providerErrorHandler(ev)}
|
||||||
@@ -347,7 +325,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
class=${classMap(classes)}
|
class=${classMap(classes)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.camera=${this.camera}
|
.camera=${this.camera}
|
||||||
.cameraEndpoints=${this.cameraEndpoints}
|
.targetID=${this.targetID}
|
||||||
.microphoneState=${this.microphoneState}
|
.microphoneState=${this.microphoneState}
|
||||||
.microphoneConfig=${this.liveConfig.microphone}
|
.microphoneConfig=${this.liveConfig.microphone}
|
||||||
?controls=${this._getEffectiveBuiltinControls()}
|
?controls=${this._getEffectiveBuiltinControls()}
|
||||||
@@ -360,8 +338,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
class=${classMap(classes)}
|
class=${classMap(classes)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.camera=${this.camera}
|
||||||
.cameraEndpoints=${this.cameraEndpoints}
|
.targetID=${this.targetID}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
?controls=${this._getEffectiveBuiltinControls()}
|
?controls=${this._getEffectiveBuiltinControls()}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
||||||
@@ -373,8 +351,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
class=${classMap(classes)}
|
class=${classMap(classes)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.camera=${this.camera}
|
||||||
.cameraEndpoints=${this.cameraEndpoints}
|
.targetID=${this.targetID}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
||||||
this._providerErrorHandler(ev)}
|
this._providerErrorHandler(ev)}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { Camera } from '../../../../camera-manager/camera.js';
|
import { Camera } from '../../../../camera-manager/camera.js';
|
||||||
import { CameraEndpoints } from '../../../../camera-manager/types.js';
|
|
||||||
import { MicrophoneState } from '../../../../card-controller/types.js';
|
import { MicrophoneState } from '../../../../card-controller/types.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
||||||
@@ -31,8 +30,9 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public camera?: Camera;
|
public camera?: Camera;
|
||||||
|
|
||||||
|
// The BASE camera ID (camera property may be a substream)
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraEndpoints?: CameraEndpoints;
|
public targetID?: string;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneState?: MicrophoneState;
|
public microphoneState?: MicrophoneState;
|
||||||
@@ -55,7 +55,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
private _signedURLController = new SignedURLController(
|
private _signedURLController = new SignedURLController(
|
||||||
this,
|
this,
|
||||||
() => {
|
() => {
|
||||||
const endpoint = this.cameraEndpoints?.go2rtc;
|
const endpoint = this.camera?.getEndpoints()?.go2rtc;
|
||||||
if (!this.hass || !endpoint) {
|
if (!this.hass || !endpoint) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._player = new VideoRTC();
|
this._player = new VideoRTC();
|
||||||
|
this._player.targetID = this.targetID ?? null;
|
||||||
this._player.mediaPlayerController = this._mediaPlayerController;
|
this._player.mediaPlayerController = this._mediaPlayerController;
|
||||||
this._player.microphoneStream = this.microphoneState?.stream ?? null;
|
this._player.microphoneStream = this.microphoneState?.stream ?? null;
|
||||||
this._player.src = src;
|
this._player.src = src;
|
||||||
@@ -108,17 +109,17 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('cameraEndpoints')) {
|
if (changedProps.has('camera')) {
|
||||||
// Clear old player; the new one is created by the
|
// Clear old player; the new one is created by the
|
||||||
// SignedURLController's valueChangeCallback once the URL resolves.
|
// SignedURLController's valueChangeCallback once the URL resolves.
|
||||||
this._player = undefined;
|
this._player = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only treat a missing go2rtc endpoint as an error after cameraEndpoints
|
// Only treat a missing go2rtc endpoint as an error after the camera's
|
||||||
// has been explicitly set (not undefined / still loading).
|
// endpoints have been explicitly set (not undefined / still loading).
|
||||||
|
const endpoints = this.camera?.getEndpoints();
|
||||||
const hasError =
|
const hasError =
|
||||||
!!this._signedURLController.getError() ||
|
!!this._signedURLController.getError() || (!!endpoints && !endpoints.go2rtc);
|
||||||
(!!this.cameraEndpoints && !this.cameraEndpoints.go2rtc);
|
|
||||||
if (hasError && !this._hasLiveError) {
|
if (hasError && !this._hasLiveError) {
|
||||||
dispatchLiveErrorEvent(this);
|
dispatchLiveErrorEvent(this);
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
{ context: this.camera?.getConfig() },
|
{ context: this.camera?.getConfig() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!this.cameraEndpoints?.go2rtc) {
|
if (!this.camera?.getEndpoints()?.go2rtc) {
|
||||||
return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), {
|
return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), {
|
||||||
context: this.camera?.getConfig(),
|
context: this.camera?.getConfig(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
// Custom methods/members.
|
// Custom methods/members.
|
||||||
mediaPlayerController: MediaPlayerController | null;
|
mediaPlayerController: MediaPlayerController | null;
|
||||||
microphoneStream: MediaStream | null;
|
microphoneStream: MediaStream | null;
|
||||||
|
targetID: string | null;
|
||||||
reconnect();
|
reconnect();
|
||||||
setControls(controls: boolean): void;
|
setControls(controls: boolean): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
setControlsOnVideo,
|
setControlsOnVideo,
|
||||||
} from '../../../../utils/controls.js';
|
} from '../../../../utils/controls.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
createMediaLoadedInfo,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
@@ -171,6 +171,20 @@ export class VideoRTC extends HTMLElement {
|
|||||||
*/
|
*/
|
||||||
this.mediaPlayerController = null;
|
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*.
|
* Whether to show or hide video controls for videos created *in future*.
|
||||||
* @type {boolean}}
|
* @type {boolean}}
|
||||||
@@ -188,7 +202,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
* Dispatch a media loaded event with current capabilities.
|
* Dispatch a media loaded event with current capabilities.
|
||||||
*/
|
*/
|
||||||
_dispatchMediaLoadedEvent() {
|
_dispatchMediaLoadedEvent() {
|
||||||
dispatchMediaLoadedEvent(this, this.video, {
|
const info = createMediaLoadedInfo(this.video, {
|
||||||
...(this.mediaPlayerController && {
|
...(this.mediaPlayerController && {
|
||||||
mediaPlayerController: this.mediaPlayerController,
|
mediaPlayerController: this.mediaPlayerController,
|
||||||
}),
|
}),
|
||||||
@@ -199,6 +213,27 @@ export class VideoRTC extends HTMLElement {
|
|||||||
},
|
},
|
||||||
technology: getTechnologyForVideoRTC(this),
|
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.
|
* document's DOM.
|
||||||
*/
|
*/
|
||||||
disconnectedCallback() {
|
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.background || this.disconnectTID) return;
|
||||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||||
|
|
||||||
@@ -767,12 +808,15 @@ export class VideoRTC extends HTMLElement {
|
|||||||
|
|
||||||
if (!receivedFirstFrame) {
|
if (!receivedFirstFrame) {
|
||||||
receivedFirstFrame = true;
|
receivedFirstFrame = true;
|
||||||
dispatchMediaLoadedEvent(this, this.video, {
|
const info = createMediaLoadedInfo(this.video, {
|
||||||
...(this.mediaPlayerController && {
|
...(this.mediaPlayerController && {
|
||||||
mediaPlayerController: this.mediaPlayerController,
|
mediaPlayerController: this.mediaPlayerController,
|
||||||
}),
|
}),
|
||||||
technology: ['mjpeg'],
|
technology: ['mjpeg'],
|
||||||
});
|
});
|
||||||
|
if (info) {
|
||||||
|
this._dispatchMediaLoadedInfo(info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -814,12 +858,15 @@ export class VideoRTC extends HTMLElement {
|
|||||||
canvas.height = video2.videoHeight;
|
canvas.height = video2.videoHeight;
|
||||||
context = canvas.getContext('2d');
|
context = canvas.getContext('2d');
|
||||||
|
|
||||||
dispatchMediaLoadedEvent(this, video2, {
|
const info = createMediaLoadedInfo(video2, {
|
||||||
...(this.mediaPlayerController && {
|
...(this.mediaPlayerController && {
|
||||||
mediaPlayerController: this.mediaPlayerController,
|
mediaPlayerController: this.mediaPlayerController,
|
||||||
}),
|
}),
|
||||||
technology: ['mp4'],
|
technology: ['mp4'],
|
||||||
});
|
});
|
||||||
|
if (info) {
|
||||||
|
this._dispatchMediaLoadedInfo(info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.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 { HomeAssistant } from '../../../ha/types';
|
||||||
import '../../../patches/ha-camera-stream';
|
import '../../../patches/ha-camera-stream';
|
||||||
import '../../../patches/ha-hls-player.js';
|
import '../../../patches/ha-hls-player.js';
|
||||||
@@ -19,7 +19,11 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer
|
|||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@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 })
|
@property({ attribute: true, type: Boolean })
|
||||||
public controls = false;
|
public controls = false;
|
||||||
@@ -36,14 +40,14 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cameraEntity = this.camera?.getConfig()?.camera_entity;
|
||||||
return html` <advanced-camera-card-ha-camera-stream
|
return html` <advanced-camera-card-ha-camera-stream
|
||||||
${ref(this._playerRef)}
|
${ref(this._playerRef)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.stateObj=${this.cameraConfig?.camera_entity
|
.stateObj=${cameraEntity ? this.hass.states[cameraEntity] : undefined}
|
||||||
? this.hass.states[this.cameraConfig.camera_entity]
|
|
||||||
: undefined}
|
|
||||||
.controls=${this.controls}
|
.controls=${this.controls}
|
||||||
.muted=${true}
|
.muted=${true}
|
||||||
|
.targetID=${this.targetID}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-ha-camera-stream>`;
|
</advanced-camera-card-ha-camera-stream>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import { CameraConfig } from '../../../config/schema/cameras';
|
import { Camera } from '../../../camera-manager/camera.js';
|
||||||
import { EnabledProxyConfig } from '../../../config/schema/common/proxy';
|
|
||||||
import { HomeAssistant } from '../../../ha/types';
|
import { HomeAssistant } from '../../../ha/types';
|
||||||
import basicBlockStyle from '../../../scss/basic-block.scss';
|
import basicBlockStyle from '../../../scss/basic-block.scss';
|
||||||
import {
|
import {
|
||||||
@@ -18,10 +17,11 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
|||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public camera?: Camera;
|
||||||
|
|
||||||
|
// The BASE camera ID (camera property may be a substream)
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public proxyConfig?: EnabledProxyConfig;
|
public targetID?: string;
|
||||||
|
|
||||||
private _refImage: Ref<MediaPlayerElement> = createRef();
|
private _refImage: Ref<MediaPlayerElement> = createRef();
|
||||||
|
|
||||||
@@ -31,7 +31,8 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.cameraConfig) {
|
const cameraConfig = this.camera?.getConfig();
|
||||||
|
if (!this.hass || !cameraConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +40,10 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
|||||||
<advanced-camera-card-image-updating-player
|
<advanced-camera-card-image-updating-player
|
||||||
${ref(this._refImage)}
|
${ref(this._refImage)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.imageConfig=${this.cameraConfig.image}
|
.imageConfig=${cameraConfig.image}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
.proxyConfig=${this.proxyConfig}
|
.targetID=${this.targetID}
|
||||||
|
.proxyConfig=${this.camera?.getLiveProxyConfig()}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-image-updating-player>
|
</advanced-camera-card-image-updating-player>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { until } from 'lit/directives/until.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 { 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 { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
|
||||||
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
||||||
import { Notification } from '../../../config/schema/actions/types.js';
|
import { Notification } from '../../../config/schema/actions/types.js';
|
||||||
import { CameraConfig } from '../../../config/schema/cameras.js';
|
|
||||||
import { CardWideConfig } from '../../../config/schema/types.js';
|
import { CardWideConfig } from '../../../config/schema/types.js';
|
||||||
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
|
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
|
||||||
import { HomeAssistant } from '../../../ha/types.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 { MediaPlayer, MediaPlayerController } from '../../../types.js';
|
||||||
import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
|
import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
createMediaLoadedInfo,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
} from '../../../utils/media-info.js';
|
} from '../../../utils/media-info.js';
|
||||||
@@ -44,10 +44,11 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
private hass?: HomeAssistant;
|
private hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public camera?: Camera;
|
||||||
|
|
||||||
|
// The BASE camera ID (camera property may be a substream)
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraEndpoints?: CameraEndpoints;
|
public targetID?: string;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
@@ -65,14 +66,16 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
() => this._jsmpegCanvasElement ?? null,
|
() => this._jsmpegCanvasElement ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
if (
|
if (changedProperties.has('camera')) {
|
||||||
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
|
|
||||||
) {
|
|
||||||
this._notification = null;
|
this._notification = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +102,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
preserveDrawingBuffer: true,
|
preserveDrawingBuffer: true,
|
||||||
|
|
||||||
// Override with user-specified options.
|
// 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
|
// 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
|
// 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
|
// The media-loaded info must be reported after the player is assigned to
|
||||||
// `this._jsmpegVideoPlayer`, since the load call may (will!) result in
|
// `this._jsmpegVideoPlayer`, since the registration may result in calls
|
||||||
// calls back to the player to check for pause status for menu buttons.
|
// back to the player to check for pause status for menu buttons.
|
||||||
if (this._jsmpegCanvasElement) {
|
if (this._jsmpegCanvasElement) {
|
||||||
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, {
|
const info = createMediaLoadedInfo(this._jsmpegCanvasElement, {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsPause: true,
|
supportsPause: true,
|
||||||
},
|
},
|
||||||
technology: ['jsmpeg'],
|
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 = document.createElement('canvas');
|
||||||
this._jsmpegCanvasElement.className = 'media';
|
this._jsmpegCanvasElement.className = 'media';
|
||||||
|
|
||||||
const endpoint = this.cameraEndpoints?.jsmpeg;
|
const endpoint = this.camera?.getEndpoints()?.jsmpeg;
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
this._notification = createNotificationFromText(
|
this._notification = createNotificationFromText(
|
||||||
localize('error.live_camera_no_endpoint'),
|
localize('error.live_camera_no_endpoint'),
|
||||||
{ context: this.cameraConfig },
|
{ context: this.camera?.getConfig() },
|
||||||
);
|
);
|
||||||
dispatchLiveErrorEvent(this);
|
dispatchLiveErrorEvent(this);
|
||||||
return;
|
return;
|
||||||
@@ -199,7 +205,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
|
|
||||||
if (!address) {
|
if (!address) {
|
||||||
this._notification = createNotificationFromText(localize('error.failed_sign'), {
|
this._notification = createNotificationFromText(localize('error.failed_sign'), {
|
||||||
context: this.cameraConfig,
|
context: this.camera?.getConfig(),
|
||||||
});
|
});
|
||||||
dispatchLiveErrorEvent(this);
|
dispatchLiveErrorEvent(this);
|
||||||
return;
|
return;
|
||||||
@@ -224,7 +230,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
if (!this._notification) {
|
if (!this._notification) {
|
||||||
this._notification = createNotificationFromText(
|
this._notification = createNotificationFromText(
|
||||||
localize('error.jsmpeg_no_player'),
|
localize('error.jsmpeg_no_player'),
|
||||||
{ context: this.cameraConfig },
|
{ context: this.camera?.getConfig() },
|
||||||
);
|
);
|
||||||
dispatchLiveErrorEvent(this);
|
dispatchLiveErrorEvent(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
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 { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.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 { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
|
||||||
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
||||||
import { Notification } from '../../../config/schema/actions/types.js';
|
import { Notification } from '../../../config/schema/actions/types.js';
|
||||||
import { CameraConfig } from '../../../config/schema/cameras.js';
|
|
||||||
import { CardWideConfig } from '../../../config/schema/types.js';
|
import { CardWideConfig } from '../../../config/schema/types.js';
|
||||||
import { HomeAssistant } from '../../../ha/types.js';
|
import { HomeAssistant } from '../../../ha/types.js';
|
||||||
import { localize } from '../../../localize/localize.js';
|
import { localize } from '../../../localize/localize.js';
|
||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from '../../../utils/controls.js';
|
} from '../../../utils/controls.js';
|
||||||
import { getContextFromError } from '../../../utils/error-context.js';
|
import { getContextFromError } from '../../../utils/error-context.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
createMediaLoadedInfo,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
@@ -49,10 +49,11 @@ import { VideoRTC } from './go2rtc/video-rtc.js';
|
|||||||
@customElement('advanced-camera-card-live-webrtc-card')
|
@customElement('advanced-camera-card-live-webrtc-card')
|
||||||
export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements MediaPlayer {
|
export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements MediaPlayer {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public camera?: Camera;
|
||||||
|
|
||||||
|
// The BASE camera ID (camera property may be a substream)
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraEndpoints?: CameraEndpoints;
|
public targetID?: string;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
@@ -73,6 +74,10 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
|||||||
() => this.controls,
|
() => this.controls,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
@@ -95,9 +100,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
if (
|
if (changedProperties.has('camera')) {
|
||||||
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
|
|
||||||
) {
|
|
||||||
this._notification = null;
|
this._notification = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +123,8 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
|||||||
*/
|
*/
|
||||||
private _createWebRTC(): HTMLElement | null {
|
private _createWebRTC(): HTMLElement | null {
|
||||||
const webrtcElement = this._webrtcTask.value;
|
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 & {
|
const webrtc = new webrtcElement() as HTMLElement & {
|
||||||
hass: HomeAssistant;
|
hass: HomeAssistant;
|
||||||
setConfig: (config: Record<string, unknown>) => void;
|
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
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1654
|
||||||
muted: true,
|
muted: true,
|
||||||
|
|
||||||
...this.cameraConfig.webrtc_card,
|
...cameraConfig.webrtc_card,
|
||||||
};
|
};
|
||||||
if (!config.url && !config.entity && this.cameraEndpoints?.webrtcCard) {
|
const webrtcCardEndpoint = this.camera?.getEndpoints()?.webrtcCard;
|
||||||
config.entity = this.cameraEndpoints.webrtcCard.endpoint;
|
if (!config.url && !config.entity && webrtcCardEndpoint) {
|
||||||
|
config.entity = webrtcCardEndpoint.endpoint;
|
||||||
}
|
}
|
||||||
webrtc.setConfig(config);
|
webrtc.setConfig(config);
|
||||||
webrtc.hass = this.hass;
|
webrtc.hass = this.hass;
|
||||||
@@ -204,7 +209,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
|||||||
if (this.controls) {
|
if (this.controls) {
|
||||||
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
||||||
}
|
}
|
||||||
dispatchMediaLoadedEvent(this, video, {
|
const info = createMediaLoadedInfo(video, {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsPause: true,
|
supportsPause: true,
|
||||||
@@ -214,6 +219,9 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
|||||||
technology: getTechnologyForVideoRTC(this._videoRTC),
|
technology: getTechnologyForVideoRTC(this._videoRTC),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (info) {
|
||||||
|
this._mediaLoadedInfoSourceController.set(info);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
video.onplay = () => dispatchMediaPlayEvent(this);
|
video.onplay = () => dispatchMediaPlayEvent(this);
|
||||||
video.onpause = () => dispatchMediaPauseEvent(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 { customElement, property } from 'lit/decorators.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.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 { VideoMediaPlayerController } from '../components-lib/media-player/video';
|
||||||
import videoPlayerStyle from '../scss/video-player.scss';
|
import videoPlayerStyle from '../scss/video-player.scss';
|
||||||
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types';
|
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types';
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
} from '../utils/controls';
|
} from '../utils/controls';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
createMediaLoadedInfo,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
@@ -22,6 +23,9 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
|||||||
@property()
|
@property()
|
||||||
public url?: string;
|
public url?: string;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
public targetID?: string;
|
||||||
|
|
||||||
@property({ type: Boolean })
|
@property({ type: Boolean })
|
||||||
public controls = false;
|
public controls = false;
|
||||||
|
|
||||||
@@ -32,6 +36,10 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
|||||||
() => this.controls,
|
() => this.controls,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
@@ -54,7 +62,7 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@loadeddata="${(ev: Event) => {
|
@loadeddata="${(ev: Event) => {
|
||||||
dispatchMediaLoadedEvent(this, ev, {
|
const info = createMediaLoadedInfo(ev, {
|
||||||
...(this._mediaPlayerController && {
|
...(this._mediaPlayerController && {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
}),
|
}),
|
||||||
@@ -64,6 +72,9 @@ export class AdvancedCameraCardVideoPlayer extends LitElement implements MediaPl
|
|||||||
},
|
},
|
||||||
technology: ['mp4'],
|
technology: ['mp4'],
|
||||||
});
|
});
|
||||||
|
if (info) {
|
||||||
|
this._mediaLoadedInfoSourceController.set(info);
|
||||||
|
}
|
||||||
}}"
|
}}"
|
||||||
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||||
@play=${() => dispatchMediaPlayEvent(this)}
|
@play=${() => dispatchMediaPlayEvent(this)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { RemoveContextPropertyViewModifier } from '../../card-controller/view/mo
|
|||||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||||
import { MediaHeightController } from '../../components-lib/media-height-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 { TransitionEffect } from '../../config/schema/common/transition-effect.js';
|
||||||
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
|
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
|
||||||
import { ViewerConfig } from '../../config/schema/viewer.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 { localize } from '../../localize/localize.js';
|
||||||
import '../../patches/ha-hls-player.js';
|
import '../../patches/ha-hls-player.js';
|
||||||
import viewerCarouselStyle from '../../scss/viewer-carousel.scss';
|
import viewerCarouselStyle from '../../scss/viewer-carousel.scss';
|
||||||
import { MediaLoadedInfo, MediaPlayerController } from '../../types.js';
|
|
||||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||||
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
|
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
|
||||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.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 { getTextDirection } from '../../utils/text-direction.js';
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||||
import { ViewMedia } from '../../view/item.js';
|
import { ViewMedia } from '../../view/item.js';
|
||||||
import '../carousel';
|
import '../carousel';
|
||||||
import type { EmblaCarouselPlugins } from '../carousel.js';
|
|
||||||
import '../next-prev-control.js';
|
import '../next-prev-control.js';
|
||||||
import { renderNoMedia } from '../notification/no-media.js';
|
import { renderNoMedia } from '../notification/no-media.js';
|
||||||
import '../ptz.js';
|
import '../ptz.js';
|
||||||
@@ -87,9 +85,17 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
private _media: ViewMedia[] | null = null;
|
private _media: ViewMedia[] | null = null;
|
||||||
private _mediaActionsController = new MediaActionsController();
|
private _mediaActionsController = new MediaActionsController();
|
||||||
private _mediaHeightController = new MediaHeightController(this, '.embla__slide');
|
private _mediaHeightController = new MediaHeightController(this, '.embla__slide');
|
||||||
private _loadedMediaPlayerController: MediaPlayerController | null = null;
|
|
||||||
private _refCarousel: Ref<HTMLElement> = createRef();
|
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 {
|
public connectedCallback(): void {
|
||||||
super.connectedCallback();
|
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.
|
* Get the previous and next true media items from the current view.
|
||||||
* @returns A BrowseMediaNeighbors with indices and objects of true media
|
* @returns A BrowseMediaNeighbors with indices and objects of true media
|
||||||
@@ -335,21 +333,12 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
<advanced-camera-card-carousel
|
<advanced-camera-card-carousel
|
||||||
${ref(this._refCarousel)}
|
${ref(this._refCarousel)}
|
||||||
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
||||||
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
|
|
||||||
.selected=${this._selected}
|
.selected=${this._selected}
|
||||||
.wheelScrolling=${this.viewerConfig?.controls.wheel}
|
.wheelScrolling=${this.viewerConfig?.controls.wheel}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@advanced-camera-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
|
@advanced-camera-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
|
||||||
this._setViewSelectedIndex(ev.detail.index);
|
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) : ''}
|
${this.showControls ? this._renderNextPrevious('left', neighbors) : ''}
|
||||||
${guard([this._media, view], () => this._getSlides())}
|
${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.
|
* Fire a media show event when a slide is selected.
|
||||||
*/
|
*/
|
||||||
private async _seekHandler(): Promise<void> {
|
private async _seekHandler(): Promise<void> {
|
||||||
|
const mediaPlayerController =
|
||||||
|
this._mediaLoadedInfoSinkController.get()?.mediaPlayerController ?? null;
|
||||||
if (
|
if (
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this._media ||
|
!this._media ||
|
||||||
!this._loadedMediaPlayerController ||
|
!mediaPlayerController ||
|
||||||
this._selected === null
|
this._selected === null
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -442,17 +433,17 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
|
|
||||||
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
||||||
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
|
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
|
||||||
if (!seekTimeInMedia && !this._loadedMediaPlayerController.isPaused()) {
|
if (!seekTimeInMedia && !mediaPlayerController.isPaused()) {
|
||||||
this._loadedMediaPlayerController.pause();
|
mediaPlayerController.pause();
|
||||||
} else if (seekTimeInMedia && this._loadedMediaPlayerController.isPaused()) {
|
} else if (seekTimeInMedia && mediaPlayerController.isPaused()) {
|
||||||
this._loadedMediaPlayerController.play();
|
mediaPlayerController.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekTime =
|
const seekTime =
|
||||||
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
|
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
|
||||||
|
|
||||||
if (seekTime !== 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
|
// Note: crossorigin="anonymous" is required on <video> below in order to
|
||||||
// allow screenshot of motionEye videos which currently go cross-origin.
|
// allow screenshot of motionEye videos which currently go cross-origin.
|
||||||
|
const mediaID = this.media.getID() ?? undefined;
|
||||||
return this._renderContainer(html`
|
return this._renderContainer(html`
|
||||||
${ViewItemClassifier.isVideo(this.media)
|
${ViewItemClassifier.isVideo(this.media)
|
||||||
? this.media.getVideoContentType() === VideoContentType.HLS
|
? this.media.getVideoContentType() === VideoContentType.HLS
|
||||||
@@ -265,6 +266,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
url=${url}
|
url=${url}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.targetID=${mediaID}
|
||||||
?controls=${this.viewerConfig.controls.builtin}
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-ha-hls-player>`
|
</advanced-camera-card-ha-hls-player>`
|
||||||
@@ -274,6 +276,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
url=${url}
|
url=${url}
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
|
.targetID=${mediaID}
|
||||||
?controls=${this.viewerConfig.controls.builtin}
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-video-player>
|
</advanced-camera-card-video-player>
|
||||||
@@ -283,6 +286,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
url="${url}"
|
url="${url}"
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
|
.targetID=${mediaID}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
||||||
this._switchToRelatedClipView();
|
this._switchToRelatedClipView();
|
||||||
|
|||||||
@@ -10,12 +10,18 @@
|
|||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
|
||||||
import { css, CSSResultGroup, html, nothing, PropertyValues, unsafeCSS } from 'lit';
|
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 { query } from 'lit/decorators/query.js';
|
||||||
|
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
||||||
import '../components/image-player.js';
|
import '../components/image-player.js';
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
import { MediaLoadedInfo, MediaPlayer, MediaPlayerController } from '../types.js';
|
import {
|
||||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../utils/media-info.js';
|
MediaLoadedInfo,
|
||||||
|
MediaLoadedInfoEventDetail,
|
||||||
|
MediaPlayer,
|
||||||
|
MediaPlayerController,
|
||||||
|
} from '../types.js';
|
||||||
|
import { onAbort } from '../utils/abort-signal.js';
|
||||||
import './ha-hls-player.js';
|
import './ha-hls-player.js';
|
||||||
import './ha-web-rtc-player.js';
|
import './ha-web-rtc-player.js';
|
||||||
|
|
||||||
@@ -45,8 +51,21 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
@query('.player:not(.hidden)')
|
@query('.player:not(.hidden)')
|
||||||
protected _player: MediaPlayer;
|
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 _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {};
|
||||||
private _mediaLoadedInfoDispatched: MediaLoadedInfo | null = null;
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ========================================================================================
|
// ========================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
@@ -58,16 +77,20 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
return (await this._player?.getMediaPlayerController()) ?? null;
|
return (await this._player?.getMediaPlayerController()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _storeMediaLoadedInfoHandler(
|
private _captureInnerLoad(
|
||||||
stream: StreamType,
|
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();
|
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();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +101,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
if (stream.type === STREAM_TYPE_MJPEG) {
|
if (stream.type === STREAM_TYPE_MJPEG) {
|
||||||
return html`
|
return html`
|
||||||
<advanced-camera-card-image-player
|
<advanced-camera-card-image-player
|
||||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
.targetID=${this.targetID}
|
||||||
this._storeMediaLoadedInfo(STREAM_TYPE_MJPEG, ev.detail);
|
@advanced-camera-card:media:loaded=${(
|
||||||
ev.stopPropagation();
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
}}
|
) => this._captureInnerLoad(STREAM_TYPE_MJPEG, ev)}
|
||||||
src=${typeof this._connected == 'undefined' || this._connected
|
src=${typeof this._connected == 'undefined' || this._connected
|
||||||
? computeMJPEGStreamUrl(this.stateObj)
|
? computeMJPEGStreamUrl(this.stateObj)
|
||||||
: this._posterUrl || ''}
|
: this._posterUrl || ''}
|
||||||
@@ -101,10 +124,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.entityid=${this.stateObj.entity_id}
|
.entityid=${this.stateObj.entity_id}
|
||||||
.posterUrl=${this._posterUrl}
|
.posterUrl=${this._posterUrl}
|
||||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
.targetID=${this.targetID}
|
||||||
this._storeMediaLoadedInfoHandler(STREAM_TYPE_HLS, ev);
|
@advanced-camera-card:media:loaded=${(
|
||||||
ev.stopPropagation();
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
}}
|
) => this._captureInnerLoad(STREAM_TYPE_HLS, ev)}
|
||||||
@streams=${this._handleHlsStreams}
|
@streams=${this._handleHlsStreams}
|
||||||
class="player ${stream.visible ? '' : 'hidden'}"
|
class="player ${stream.visible ? '' : 'hidden'}"
|
||||||
></advanced-camera-card-ha-hls-player>`;
|
></advanced-camera-card-ha-hls-player>`;
|
||||||
@@ -119,10 +142,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.entityid=${this.stateObj.entity_id}
|
.entityid=${this.stateObj.entity_id}
|
||||||
.posterUrl=${this._posterUrl}
|
.posterUrl=${this._posterUrl}
|
||||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
.targetID=${this.targetID}
|
||||||
this._storeMediaLoadedInfoHandler(STREAM_TYPE_WEB_RTC, ev);
|
@advanced-camera-card:media:loaded=${(
|
||||||
ev.stopPropagation();
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
}}
|
) => this._captureInnerLoad(STREAM_TYPE_WEB_RTC, ev)}
|
||||||
@streams=${this._handleWebRtcStreams}
|
@streams=${this._handleWebRtcStreams}
|
||||||
class="player ${stream.visible ? '' : 'hidden'}"
|
class="player ${stream.visible ? '' : 'hidden'}"
|
||||||
></advanced-camera-card-ha-web-rtc-player>`;
|
></advanced-camera-card-ha-web-rtc-player>`;
|
||||||
@@ -141,13 +164,13 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
this.muted,
|
this.muted,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Republish the visible stream's cached info as our own.
|
||||||
const visibleStream = streams.find((stream) => stream.visible) ?? null;
|
const visibleStream = streams.find((stream) => stream.visible) ?? null;
|
||||||
if (visibleStream) {
|
const mediaLoadedInfo = visibleStream
|
||||||
const mediaLoadedInfo = this._mediaLoadedInfoPerStream[visibleStream.type];
|
? this._mediaLoadedInfoPerStream[visibleStream.type]
|
||||||
if (mediaLoadedInfo && mediaLoadedInfo !== this._mediaLoadedInfoDispatched) {
|
: null;
|
||||||
this._mediaLoadedInfoDispatched = mediaLoadedInfo;
|
if (mediaLoadedInfo) {
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
|
this._mediaLoadedInfoSourceController.set(mediaLoadedInfo);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
|
||||||
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
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 { query } from 'lit/decorators/query.js';
|
||||||
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.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 { VideoMediaPlayerController } from '../components-lib/media-player/video.js';
|
||||||
import { renderNotificationBlockFromText } from '../components/notification/block.js';
|
import { renderNotificationBlockFromText } from '../components/notification/block.js';
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
@@ -24,7 +25,7 @@ import {
|
|||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
} from '../utils/controls.js';
|
} from '../utils/controls.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
createMediaLoadedInfo,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
@@ -42,12 +43,22 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
@query('#video')
|
@query('#video')
|
||||||
protected _video: HTMLVideoElement;
|
protected _video: HTMLVideoElement;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public targetID?: string;
|
||||||
|
|
||||||
private _mediaPlayerController = new VideoMediaPlayerController(
|
private _mediaPlayerController = new VideoMediaPlayerController(
|
||||||
this,
|
this,
|
||||||
() => this._video,
|
() => this._video,
|
||||||
() => this.controls,
|
() => this.controls,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
@@ -93,7 +104,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
|
|
||||||
private _loadedDataHandler(ev: Event) {
|
private _loadedDataHandler(ev: Event) {
|
||||||
super._loadedData();
|
super._loadedData();
|
||||||
dispatchMediaLoadedEvent(this, ev, {
|
const info = createMediaLoadedInfo(ev, {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsPause: true,
|
supportsPause: true,
|
||||||
@@ -101,6 +112,9 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
},
|
},
|
||||||
technology: ['hls'],
|
technology: ['hls'],
|
||||||
});
|
});
|
||||||
|
if (info) {
|
||||||
|
this._mediaLoadedInfoSourceController.set(info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
|
||||||
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
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 { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.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 { VideoMediaPlayerController } from '../components-lib/media-player/video.js';
|
||||||
import { renderNotificationBlockFromText } from '../components/notification/block.js';
|
import { renderNotificationBlockFromText } from '../components/notification/block.js';
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
} from '../utils/controls.js';
|
} from '../utils/controls.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
createMediaLoadedInfo,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
@@ -42,12 +43,22 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
@customElement('advanced-camera-card-ha-web-rtc-player')
|
@customElement('advanced-camera-card-ha-web-rtc-player')
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer {
|
class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public targetID?: string;
|
||||||
|
|
||||||
private _mediaPlayerController = new VideoMediaPlayerController(
|
private _mediaPlayerController = new VideoMediaPlayerController(
|
||||||
this,
|
this,
|
||||||
() => this._videoEl,
|
() => this._videoEl,
|
||||||
() => this.controls,
|
() => this.controls,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
protected _audioTracksMuteStateCleanup: AudioTracksMuteStateCleanup = null;
|
protected _audioTracksMuteStateCleanup: AudioTracksMuteStateCleanup = null;
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
@@ -130,7 +141,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
|
|
||||||
private _loadedDataHandler(ev: Event) {
|
private _loadedDataHandler(ev: Event) {
|
||||||
super._loadedData();
|
super._loadedData();
|
||||||
dispatchMediaLoadedEvent(this, ev, {
|
const info = createMediaLoadedInfo(ev, {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsPause: true,
|
supportsPause: true,
|
||||||
@@ -138,13 +149,17 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
},
|
},
|
||||||
technology: ['webrtc'],
|
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?.();
|
||||||
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(
|
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(
|
||||||
this._peerConnection,
|
this._peerConnection,
|
||||||
() => {
|
() => {
|
||||||
dispatchMediaLoadedEvent(this, this._videoEl, {
|
const info = createMediaLoadedInfo(this._videoEl, {
|
||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsPause: true,
|
supportsPause: true,
|
||||||
@@ -152,6 +167,9 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
},
|
},
|
||||||
technology: ['webrtc'],
|
technology: ['webrtc'],
|
||||||
});
|
});
|
||||||
|
if (info) {
|
||||||
|
this._mediaLoadedInfoSourceController.set(info);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-3
@@ -43,9 +43,28 @@ export interface MediaLoadedInfo {
|
|||||||
mediaPlayerController?: MediaPlayerController;
|
mediaPlayerController?: MediaPlayerController;
|
||||||
capabilities?: MediaLoadedCapabilities;
|
capabilities?: MediaLoadedCapabilities;
|
||||||
|
|
||||||
// Whether or not this media is a placeholder (temporary image) whilst another
|
// Universal key identifying "what this media belongs to" — a camera ID for
|
||||||
// media item is being loaded.
|
// live, a media ID for the viewer, or a sentinel for the image view.
|
||||||
placeholder?: boolean;
|
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 & {
|
export type WebkitHTMLVideoElement = HTMLVideoElement & {
|
||||||
@@ -179,3 +198,13 @@ export interface EffectsManagerInterface {
|
|||||||
setContainer(container: EffectsContainer): void;
|
setContainer(container: EffectsContainer): void;
|
||||||
removeContainer(): 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;
|
return;
|
||||||
}
|
}
|
||||||
this._carousel.scrollTo(index, this._transitionEffect === 'none');
|
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 => {
|
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,
|
MediaLoadedInfo,
|
||||||
MediaPlayerController,
|
MediaPlayerController,
|
||||||
MediaTechnology,
|
MediaTechnology,
|
||||||
|
UntargetedMediaLoadedInfo,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.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;
|
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.
|
* @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(
|
export function createMediaLoadedInfo(
|
||||||
source: Event | HTMLElement,
|
source: Event | HTMLElement,
|
||||||
@@ -21,7 +25,7 @@ export function createMediaLoadedInfo(
|
|||||||
capabilities?: MediaLoadedCapabilities;
|
capabilities?: MediaLoadedCapabilities;
|
||||||
technology?: MediaTechnology[];
|
technology?: MediaTechnology[];
|
||||||
},
|
},
|
||||||
): MediaLoadedInfo | null {
|
): UntargetedMediaLoadedInfo | null {
|
||||||
let target: HTMLElement | EventTarget;
|
let target: HTMLElement | EventTarget;
|
||||||
if (source instanceof Event) {
|
if (source instanceof Event) {
|
||||||
target = source.composedPath()[0];
|
target = source.composedPath()[0];
|
||||||
@@ -52,46 +56,6 @@ export function createMediaLoadedInfo(
|
|||||||
return null;
|
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 {
|
export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void {
|
||||||
fireAdvancedCameraCardEvent(target, 'media:volumechange');
|
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
|
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 { CameraManager } from '../camera-manager/manager';
|
||||||
import { PTZAction } from '../config/schema/actions/custom/ptz';
|
import { PTZAction } from '../config/schema/actions/custom/ptz';
|
||||||
import { PTZCapabilities } from '../types';
|
import { PTZCapabilities } from '../types';
|
||||||
import { View } from '../view/view';
|
|
||||||
import { getViewTargetID } from '../view/target-id';
|
import { getViewTargetID } from '../view/target-id';
|
||||||
|
import { View } from '../view/view';
|
||||||
|
import { getStreamCameraID } from './substream';
|
||||||
|
|
||||||
export type PTZType = 'digital' | 'ptz';
|
export type PTZType = 'digital' | 'ptz';
|
||||||
interface PTZTarget {
|
interface PTZTarget {
|
||||||
@@ -17,7 +18,12 @@ export const getPTZTarget = (
|
|||||||
cameraManager?: CameraManager;
|
cameraManager?: CameraManager;
|
||||||
},
|
},
|
||||||
): PTZTarget | null => {
|
): 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) {
|
if (!targetID) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -30,7 +36,6 @@ export const getPTZTarget = (
|
|||||||
}
|
}
|
||||||
if (view.is('live')) {
|
if (view.is('live')) {
|
||||||
let type: PTZType = 'digital';
|
let type: PTZType = 'digital';
|
||||||
|
|
||||||
if (options?.type !== 'digital' && options?.cameraManager) {
|
if (options?.type !== 'digital' && options?.cameraManager) {
|
||||||
if (hasCameraTruePTZ(options.cameraManager, targetID)) {
|
if (hasCameraTruePTZ(options.cameraManager, targetID)) {
|
||||||
type = 'ptz';
|
type = 'ptz';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { getStreamCameraID } from '../utils/substream';
|
|
||||||
import { View } from './view';
|
import { View } from './view';
|
||||||
|
|
||||||
// Synthetic target ID used for the image view. The image view has no natural
|
// 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
|
// 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
|
// 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
|
// being displayed." For live, this is the *base* camera ID (substream is an
|
||||||
// substreams (live) and the image view sentinel. Media IDs, camera IDs, and
|
// implementation detail of how to play camera X, not a separate logical
|
||||||
// the image sentinel inhabit distinct namespaces so there are no collisions
|
// identity — see `getStreamCameraID` for the substream-aware variant used
|
||||||
// across view types.
|
// only inside the playback chain).
|
||||||
export const getViewTargetID = (view: View): string | null => {
|
export const getViewTargetID = (view: View): string | null => {
|
||||||
if (view.isViewerView()) {
|
if (view.isViewerView()) {
|
||||||
return view.queryResults?.getSelectedResult()?.getID() ?? null;
|
return view.queryResults?.getSelectedResult()?.getID() ?? null;
|
||||||
}
|
}
|
||||||
if (view.is('live')) {
|
if (view.is('live')) {
|
||||||
return getStreamCameraID(view);
|
return view.camera;
|
||||||
}
|
}
|
||||||
if (view.is('image')) {
|
if (view.is('image')) {
|
||||||
return IMAGE_VIEW_TARGET_ID_SENTINEL;
|
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 { 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', () => {
|
describe('MediaLoadedInfoManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should initialize', () => {
|
it('should initialize', () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const manager = new MediaLoadedInfoManager(api);
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
|
||||||
manager.initialize();
|
manager.initialize();
|
||||||
|
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||||
mediaLoadedInfo: null,
|
mediaLoadedInfo: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set', () => {
|
describe('set', () => {
|
||||||
|
it('should surface info and fire condition state for the selected target', () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const manager = new MediaLoadedInfoManager(api);
|
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.has()).toBeTruthy();
|
||||||
expect(manager.get()).toBe(mediaInfo);
|
expect(manager.get()).toBe(info);
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith(
|
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||||
expect.objectContaining({ mediaLoadedInfo: mediaInfo }),
|
mediaLoadedInfo: info,
|
||||||
);
|
});
|
||||||
expect(api.getStyleManager().setExpandedMode).toBeCalled();
|
expect(api.getStyleManager().setExpandedMode).toBeCalled();
|
||||||
expect(api.getCardElementManager().update).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 api = createCardAPI();
|
||||||
const manager = new MediaLoadedInfoManager(api);
|
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.has()).toBeFalsy();
|
||||||
expect(manager.get()).toBeNull();
|
expect(manager.get()).toBeNull();
|
||||||
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
expect(api.getConditionStateManager().setState).not.toBeCalled();
|
||||||
});
|
expect(api.getStyleManager().setExpandedMode).not.toBeCalled();
|
||||||
|
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||||
|
|
||||||
it('should get last known', () => {
|
manager.setSelected('target-1');
|
||||||
const api = createCardAPI();
|
|
||||||
const manager = new MediaLoadedInfoManager(api);
|
|
||||||
const mediaLoadedInfo = createMediaLoadedInfo();
|
|
||||||
|
|
||||||
manager.set(mediaLoadedInfo);
|
|
||||||
|
|
||||||
expect(manager.has()).toBeTruthy();
|
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();
|
manager.clear();
|
||||||
|
|
||||||
expect(manager.has()).toBeFalsy();
|
expect(manager.has()).toBeFalsy();
|
||||||
expect(manager.getLastKnown()).toBe(mediaLoadedInfo);
|
expect(api.getConditionStateManager().setState).toBeCalledWith({
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith(
|
mediaLoadedInfo: null,
|
||||||
expect.objectContaining({ mediaLoadedInfo }),
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
|
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.getView()).toBe(view);
|
||||||
expect(manager.hasView()).toBeTruthy();
|
expect(manager.hasView()).toBeTruthy();
|
||||||
expect(api.getMediaLoadedInfoManager().clear).toBeCalled();
|
expect(api.getMediaLoadedInfoManager().setSelected).toBeCalledWith('camera');
|
||||||
expect(api.getCardElementManager().scrollReset).toBeCalled();
|
expect(api.getCardElementManager().scrollReset).toBeCalled();
|
||||||
expect(api.getStyleManager().setExpandedMode).toBeCalled();
|
expect(api.getStyleManager().setExpandedMode).toBeCalled();
|
||||||
expect(api.getConditionStateManager()?.setState).toBeCalledWith({
|
expect(api.getConditionStateManager()?.setState).toBeCalledWith({
|
||||||
@@ -55,7 +55,7 @@ describe('should act correctly when view is set', () => {
|
|||||||
expect(api.getCardElementManager().update).toBeCalled();
|
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({
|
const view_1 = createView({
|
||||||
view: 'live',
|
view: 'live',
|
||||||
camera: 'camera',
|
camera: 'camera',
|
||||||
@@ -68,7 +68,6 @@ describe('should act correctly when view is set', () => {
|
|||||||
|
|
||||||
manager.setViewDefault();
|
manager.setViewDefault();
|
||||||
|
|
||||||
vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear();
|
|
||||||
vi.mocked(api.getCardElementManager().scrollReset).mockClear();
|
vi.mocked(api.getCardElementManager().scrollReset).mockClear();
|
||||||
|
|
||||||
const view_2 = createView({
|
const view_2 = createView({
|
||||||
@@ -82,9 +81,7 @@ describe('should act correctly when view is set', () => {
|
|||||||
|
|
||||||
expect(manager.getView()).toBe(view_2);
|
expect(manager.getView()).toBe(view_2);
|
||||||
|
|
||||||
// The new view is neither a major media change, nor a different view name,
|
// Same view name, so scrolling should not happen.
|
||||||
// so media clearing and scrolling should not happen.
|
|
||||||
expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled();
|
|
||||||
expect(api.getCardElementManager().scrollReset).not.toBeCalled();
|
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 {
|
import {
|
||||||
callResizeHandler,
|
callResizeHandler,
|
||||||
createLitElement,
|
createLitElement,
|
||||||
|
createMediaLoadedInfoEvent,
|
||||||
getResizeObserver,
|
getResizeObserver,
|
||||||
ResizeObserverMock,
|
ResizeObserverMock,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
@@ -676,9 +677,7 @@ describe('MediaDimensionsContainerController', () => {
|
|||||||
height: 160,
|
height: 160,
|
||||||
};
|
};
|
||||||
innerContainer.dispatchEvent(
|
innerContainer.dispatchEvent(
|
||||||
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
|
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
|
||||||
detail: mediaLoadedInfo,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(host.hasAttribute('rotated')).toBeTruthy();
|
expect(host.hasAttribute('rotated')).toBeTruthy();
|
||||||
@@ -713,18 +712,14 @@ describe('MediaDimensionsContainerController', () => {
|
|||||||
height: 160,
|
height: 160,
|
||||||
};
|
};
|
||||||
innerContainer.dispatchEvent(
|
innerContainer.dispatchEvent(
|
||||||
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
|
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
|
||||||
detail: mediaLoadedInfo,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(host.hasAttribute('rotated')).toBeTruthy();
|
expect(host.hasAttribute('rotated')).toBeTruthy();
|
||||||
host.removeAttribute('rotated');
|
host.removeAttribute('rotated');
|
||||||
|
|
||||||
innerContainer.dispatchEvent(
|
innerContainer.dispatchEvent(
|
||||||
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
|
createMediaLoadedInfoEvent({ info: mediaLoadedInfo }),
|
||||||
detail: mediaLoadedInfo,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(host.hasAttribute('rotated')).toBeFalsy();
|
expect(host.hasAttribute('rotated')).toBeFalsy();
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
MediaGridConstructorOptions,
|
MediaGridConstructorOptions,
|
||||||
MediaGridController,
|
MediaGridController,
|
||||||
} from '../../src/components-lib/media-grid-controller';
|
} from '../../src/components-lib/media-grid-controller';
|
||||||
import { MediaLoadedInfo } from '../../src/types';
|
|
||||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
|
|
||||||
import {
|
import {
|
||||||
MutationObserverMock,
|
MutationObserverMock,
|
||||||
ResizeObserverMock,
|
ResizeObserverMock,
|
||||||
@@ -82,11 +80,6 @@ const triggerResizeObserver = (hostOrCell: 'cell' | 'host'): void => {
|
|||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('MediaGridController', () => {
|
describe('MediaGridController', () => {
|
||||||
const mediaLoadedInfo: MediaLoadedInfo = {
|
|
||||||
width: 10,
|
|
||||||
height: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
||||||
@@ -175,59 +168,6 @@ describe('MediaGridController', () => {
|
|||||||
expect(controller.getSelected()).toBe('0');
|
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', () => {
|
it('should unselect', () => {
|
||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const slot = createSlot();
|
const slot = createSlot();
|
||||||
@@ -235,12 +175,10 @@ describe('MediaGridController', () => {
|
|||||||
const controller = createController(host);
|
const controller = createController(host);
|
||||||
|
|
||||||
const unselectedHandler = vi.fn();
|
const unselectedHandler = vi.fn();
|
||||||
const unloadMediaHandler = vi.fn();
|
|
||||||
host.addEventListener(
|
host.addEventListener(
|
||||||
'advanced-camera-card:media-grid:unselected',
|
'advanced-camera-card:media-grid:unselected',
|
||||||
unselectedHandler,
|
unselectedHandler,
|
||||||
);
|
);
|
||||||
host.addEventListener('advanced-camera-card:media:unloaded', unloadMediaHandler);
|
|
||||||
|
|
||||||
controller.selectCell('0');
|
controller.selectCell('0');
|
||||||
expect(controller.getSelected()).toBe('0');
|
expect(controller.getSelected()).toBe('0');
|
||||||
@@ -257,15 +195,13 @@ describe('MediaGridController', () => {
|
|||||||
expect(child.getAttribute('unselected')).toEqual('');
|
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(unselectedHandler).toBeCalledTimes(1);
|
||||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
|
||||||
|
|
||||||
// Unselecting a second time should do nothing.
|
// Unselecting a second time should do nothing.
|
||||||
controller.unselectAll();
|
controller.unselectAll();
|
||||||
|
|
||||||
expect(unselectedHandler).toBeCalledTimes(1);
|
expect(unselectedHandler).toBeCalledTimes(1);
|
||||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select in constructor', () => {
|
it('should select in constructor', () => {
|
||||||
@@ -315,7 +251,6 @@ describe('MediaGridController', () => {
|
|||||||
const children = createChildren();
|
const children = createChildren();
|
||||||
const parent = createParent({ children: children });
|
const parent = createParent({ children: children });
|
||||||
const controller = createController(parent, { selected: '1' });
|
const controller = createController(parent, { selected: '1' });
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
|
||||||
|
|
||||||
expect(controller.getSelected()).toBe('1');
|
expect(controller.getSelected()).toBe('1');
|
||||||
expect(controller.getGridSize()).toBe(3);
|
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 { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
|
||||||
import { QuerySource } from '../src/query-source';
|
import { QuerySource } from '../src/query-source';
|
||||||
import { Severity } from '../src/severity';
|
import { Severity } from '../src/severity';
|
||||||
import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
|
import {
|
||||||
|
CapabilitiesRaw,
|
||||||
|
Interaction,
|
||||||
|
MediaLoadedInfo,
|
||||||
|
MediaLoadedInfoEventDetail,
|
||||||
|
} from '../src/types';
|
||||||
import {
|
import {
|
||||||
EventViewMedia,
|
EventViewMedia,
|
||||||
ReviewViewMedia,
|
ReviewViewMedia,
|
||||||
@@ -328,18 +333,35 @@ export const createMediaLoadedInfo = (
|
|||||||
return {
|
return {
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
|
targetID: 'target-1',
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMediaLoadedInfoEvent = (
|
export const createMediaLoadedInfoEvent = (options?: {
|
||||||
mediaLoadedInfo?: MediaLoadedInfo,
|
info?: MediaLoadedInfo;
|
||||||
): CustomEvent<MediaLoadedInfo> => {
|
signal?: AbortSignal;
|
||||||
return new CustomEvent('advanced-camera-card:media:loaded', {
|
// When set, overrides `composedPath()` to return `[source]`. Use this for
|
||||||
detail: mediaLoadedInfo ?? createMediaLoadedInfo(),
|
// tests that hand the event directly to a handler (e.g. `handleLoadEvent`)
|
||||||
composed: true,
|
// 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,
|
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 => {
|
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 EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
|
||||||
import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
|
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
|
||||||
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
|
|
||||||
import {
|
import {
|
||||||
MutationObserverMock,
|
MutationObserverMock,
|
||||||
callMutationHandler,
|
callMutationHandler,
|
||||||
@@ -111,41 +110,23 @@ describe('CarouselController', () => {
|
|||||||
const children = createTestSlideNodes();
|
const children = createTestSlideNodes();
|
||||||
const parent = createParent({ children: children });
|
const parent = createParent({ children: children });
|
||||||
|
|
||||||
const forceSelectListener = vi.fn();
|
|
||||||
parent.addEventListener(
|
|
||||||
'advanced-camera-card:carousel:force-select',
|
|
||||||
forceSelectListener,
|
|
||||||
);
|
|
||||||
|
|
||||||
const carousel = new CarouselController(createRoot(), parent);
|
const carousel = new CarouselController(createRoot(), parent);
|
||||||
|
|
||||||
carousel.selectSlide(4);
|
carousel.selectSlide(4);
|
||||||
|
|
||||||
expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false);
|
expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false);
|
||||||
expect(forceSelectListener).toBeCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
detail: { index: 4, element: children[4] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not select non-existent slide', () => {
|
it('should not select non-existent slide', () => {
|
||||||
const children = createTestSlideNodes({ n: 10 });
|
const children = createTestSlideNodes({ n: 10 });
|
||||||
const parent = createParent({ children: children });
|
const parent = createParent({ children: children });
|
||||||
|
|
||||||
const forceSelectListener = vi.fn();
|
|
||||||
parent.addEventListener(
|
|
||||||
'advanced-camera-card:carousel:force-select',
|
|
||||||
forceSelectListener,
|
|
||||||
);
|
|
||||||
|
|
||||||
const carousel = new CarouselController(createRoot(), parent);
|
const carousel = new CarouselController(createRoot(), parent);
|
||||||
|
|
||||||
carousel.selectSlide(11);
|
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(getEmblaApi()?.scrollTo).not.toBeCalled();
|
||||||
expect(forceSelectListener).not.toBeCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should dispatch select event', () => {
|
it('should dispatch select event', () => {
|
||||||
@@ -191,7 +172,6 @@ describe('CarouselController', () => {
|
|||||||
const children = createTestSlideNodes({ n: 1 });
|
const children = createTestSlideNodes({ n: 1 });
|
||||||
const root = createRoot();
|
const root = createRoot();
|
||||||
const parent = createParent({ children: children });
|
const parent = createParent({ children: children });
|
||||||
const plugins = [AutoMediaLoadedInfo()];
|
|
||||||
|
|
||||||
new CarouselController(root, parent, {
|
new CarouselController(root, parent, {
|
||||||
direction: 'vertical',
|
direction: 'vertical',
|
||||||
@@ -200,7 +180,6 @@ describe('CarouselController', () => {
|
|||||||
dragFree: true,
|
dragFree: true,
|
||||||
loop: true,
|
loop: true,
|
||||||
dragEnabled: false,
|
dragEnabled: false,
|
||||||
plugins: plugins,
|
|
||||||
textDirection: 'rtl',
|
textDirection: 'rtl',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +198,7 @@ describe('CarouselController', () => {
|
|||||||
watchDrag: false,
|
watchDrag: false,
|
||||||
direction: 'rtl',
|
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 { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||||
import { EngineType } from 'embla-carousel/components/Engine';
|
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 { vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
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 = (
|
export const callEmblaHandler = (
|
||||||
emblaApi: EmblaCarouselType | null,
|
emblaApi: EmblaCarouselType | null,
|
||||||
eventName: EmblaEventType,
|
eventName: EmblaEventType,
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ import { mock } from 'vitest-mock-extended';
|
|||||||
import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types';
|
import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types';
|
||||||
import {
|
import {
|
||||||
createMediaLoadedInfo,
|
createMediaLoadedInfo,
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
|
||||||
dispatchMediaLoadedEvent,
|
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaUnloadedEvent,
|
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
isValidMediaLoadedInfo,
|
isValidMediaLoadedInfo,
|
||||||
} from '../../src/utils/media-info';
|
} 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
|
// @vitest-environment jsdom
|
||||||
describe('dispatchMediaVolumeChangeEvent', () => {
|
describe('dispatchMediaVolumeChangeEvent', () => {
|
||||||
it('should dispatch', () => {
|
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