Selecting a camera in `live.display.mode: grid` lands in two visible passes: the cell gets its 2-column width immediately but keeps its 1-row height for ~400 ms, and the rest of the grid only settles ~700 ms after the click (measured per-frame on `main`; v7.27.4 has the same two passes, v8's early position resolution just leaves the late height correction standing out as a lone vertical jump). **Cause:** a grid cell's height follows its content in the same style recalculation as its width (intrinsic media ratio, `dimensions.aspect_ratio`, or the unsized 16:9 reservation), but the slotted carousel carries an inline `max-height` from `MediaHeightController` (debounced 0.3 s + 0.1 s transition; it sizes the card outside grids). In a grid that cap can only ever delay growth: it pins the freshly widened cell at its old height until the debounce fires, and the correction then also rides the 300 ms `_throttledLayout`. `selectCell()`'s existing `forceReflow()` + `layout()` was already positioned to do this in one pass; the cap starves it of the final height. **Fix:** neutralize the cap on grid cells (`max-height: none !important`; important is needed to beat the inline style). Covers the live and viewer grids; non-grid behaviour is untouched. This also stops the cap clipping cells by their border width (it was measured on the slide's content box but applied to the cell's border box). **Trade-off worth flagging:** in the *viewer* grid a cell's carousel holds all of one camera's media. If those slides do not share one ratio, the cell now sizes to its tallest slide (letterboxing shorter ones, no re-layout per swipe) instead of tracking the selected one after a debounce. If you would rather leave the viewer untouched, the rule can be scoped to `::slotted(advanced-camera-card-live-carousel)` instead (live grid cells always hold exactly one slide); say so and I will rework the PR that way. **Verification:** - New browser test `tests/components/live/grid.browser.test.ts`: fails on any frame where the newly selected cell is selected-wide but still unselected-high. On `main` it fails with 22 such frames; with this change there are none: the click settles in a single frame (~28 ms), every cell at its final size and position. - `yarn run test`, `yarn run test:browser` (chromium, firefox and webkit for the new test), `yarn run lint`, `yarn run typecheck` pass. - No resize oscillation in a cramped viewport with an appearing/disappearing ancestor scrollbar (the #2306 scenario), on window resizes, or under a narrow-screen `grid_columns: 2` override; verified against a live HA 2026.8.3 dashboard (6 go2rtc cameras). --------- Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
import {
|
|
html,
|
|
LitElement,
|
|
unsafeCSS,
|
|
type CSSResultGroup,
|
|
type PropertyValues,
|
|
type TemplateResult,
|
|
} from 'lit';
|
|
import { customElement, property } from 'lit/decorators.js';
|
|
import { keyed } from 'lit/directives/keyed.js';
|
|
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
|
|
|
import type { CameraManager } from '../../camera-manager/manager.js';
|
|
import type { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
|
|
import type { CallSession } from '../../card-controller/call/types.js';
|
|
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
|
|
import type { MicrophoneState } from '../../card-controller/types.js';
|
|
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
|
import { resolveAutoHideState } from '../../components-lib/auto-hide.js';
|
|
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
|
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
|
|
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
|
import { PTZDragController } from '../../components-lib/ptz/drag-controller.js';
|
|
import type { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
|
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
|
import {
|
|
ptzControlsDefaults,
|
|
type PTZControlType,
|
|
} from '../../config/schema/common/controls/ptz.js';
|
|
import type { TransitionEffect } from '../../config/schema/common/transition-effect.js';
|
|
import type { LiveConfig } from '../../config/schema/live.js';
|
|
import { configDefaults, type CardWideConfig } from '../../config/schema/types.js';
|
|
import type { HomeAssistant } from '../../ha/types.js';
|
|
import liveCarouselStyle from '../../scss/live-carousel.scss?inline';
|
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
|
import type { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
|
import { getTextDirection } from '../../utils/text-direction.js';
|
|
import { getStreamCameraID } from '../../view/substream.js';
|
|
import type { View } from '../../view/view.js';
|
|
|
|
import '../call-controls.js';
|
|
import '../carousel';
|
|
import '../next-prev-control.js';
|
|
import '../ptz.js';
|
|
import './provider.js';
|
|
|
|
const ADVANCED_CAMERA_CARD_LIVE_PROVIDER = 'advanced-camera-card-live-provider';
|
|
|
|
interface CameraNeighbor {
|
|
id: string;
|
|
metadata?: CameraManagerCameraMetadata | null;
|
|
}
|
|
|
|
interface CameraNeighbors {
|
|
previous?: CameraNeighbor;
|
|
next?: CameraNeighbor;
|
|
}
|
|
|
|
@customElement('advanced-camera-card-live-carousel')
|
|
export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|
@property({ attribute: false })
|
|
public hass?: HomeAssistant;
|
|
|
|
@property({ attribute: false })
|
|
public stateWatcher?: StateWatcherSubscriptionInterface;
|
|
|
|
@property({ attribute: false })
|
|
public viewManagerEpoch?: ViewManagerEpoch;
|
|
|
|
@property({ attribute: false })
|
|
public liveConfig?: LiveConfig;
|
|
|
|
@property({ attribute: false })
|
|
public cardWideConfig?: CardWideConfig;
|
|
|
|
@property({ attribute: false })
|
|
public cameraManager?: CameraManager;
|
|
|
|
@property({ attribute: false })
|
|
public microphoneState?: MicrophoneState;
|
|
|
|
@property({ attribute: false })
|
|
public call?: CallSession;
|
|
|
|
@property({ attribute: false })
|
|
public locked?: boolean;
|
|
|
|
@property({ attribute: false })
|
|
public viewFilterCameraID?: string;
|
|
|
|
// Whether this carousel automatically sets its own height (via
|
|
// MediaHeightController) to fit the media it shows. Counter-example: A grid
|
|
// cell is sized by its grid instead.
|
|
@property({ attribute: false })
|
|
public autoHeight = true;
|
|
|
|
private _refCarousel: Ref<HTMLElement> = createRef();
|
|
|
|
private _mediaActionsController = new MediaActionsController();
|
|
private _mediaHeightController = new MediaHeightController(this, '.embla__slide');
|
|
|
|
private _ptzDragController = new PTZDragController(this);
|
|
|
|
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
|
|
getTargetID: () => this._getCarouselCameraID(),
|
|
callback: () => this._mediaHeightController.recalculate(),
|
|
});
|
|
|
|
public connectedCallback(): void {
|
|
super.connectedCallback();
|
|
|
|
if (this.autoHeight) {
|
|
this._mediaHeightController.setRoot(this.renderRoot);
|
|
}
|
|
|
|
// Request update in order to reinitialize the media action controller.
|
|
this.requestUpdate();
|
|
}
|
|
|
|
public disconnectedCallback(): void {
|
|
this._mediaActionsController.destroy();
|
|
this._mediaHeightController.destroy();
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
private _getDisplayPTZType(cameraID: string | null): PTZControlType {
|
|
if (
|
|
!cameraID ||
|
|
// For cameras without physical PTZ, always display buttons so the user
|
|
// can control digital zoom. Gesture type controls have no effect on those
|
|
// cameras.
|
|
!this.cameraManager?.getCameraCapabilities(cameraID)?.hasPTZCapability()
|
|
) {
|
|
return 'buttons';
|
|
}
|
|
return (
|
|
this.viewManagerEpoch?.manager.getView()?.context?.ptzControls?.type ??
|
|
this.liveConfig?.controls.ptz.type ??
|
|
ptzControlsDefaults.type
|
|
);
|
|
}
|
|
|
|
private _isGesturesPTZActive(
|
|
view: View | null | undefined,
|
|
cameraID: string | null,
|
|
): boolean {
|
|
// _getDisplayPTZType returns 'buttons' for digital-only cameras, so this
|
|
// implicitly guards against cameras without physical PTZ capability.
|
|
return (
|
|
this._getDisplayPTZType(cameraID) === 'gestures' &&
|
|
view?.context?.ptzControls?.enabled !== false
|
|
);
|
|
}
|
|
|
|
private _getTransitionEffect = (): TransitionEffect =>
|
|
this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect;
|
|
|
|
// The cameraID this carousel currently represents: the filtered camera when
|
|
// the carousel is scoped to one, otherwise the camera of the active view.
|
|
private _getCarouselCameraID(): string | null {
|
|
return (
|
|
this.viewFilterCameraID ?? this.viewManagerEpoch?.manager.getView()?.camera ?? null
|
|
);
|
|
}
|
|
|
|
private _getSelectedCameraIndex(): number {
|
|
if (this.viewFilterCameraID) {
|
|
// If the carousel is limited to a single cameraID, the first (only)
|
|
// element is always the selected one.
|
|
return 0;
|
|
}
|
|
|
|
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
|
const view = this.viewManagerEpoch?.manager.getView();
|
|
if (!cameraIDs?.size || !view?.camera) {
|
|
return 0;
|
|
}
|
|
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
|
|
}
|
|
|
|
protected willUpdate(changedProps: PropertyValues): void {
|
|
if (changedProps.has('liveConfig')) {
|
|
this._mediaActionsController.setOptions({
|
|
playerSelector: ADVANCED_CAMERA_CARD_LIVE_PROVIDER,
|
|
...(this.liveConfig?.auto_play && {
|
|
autoPlayConditions: this.liveConfig.auto_play,
|
|
}),
|
|
...(this.liveConfig?.auto_pause && {
|
|
autoPauseConditions: this.liveConfig.auto_pause,
|
|
}),
|
|
...(this.liveConfig?.auto_mute && {
|
|
autoMuteConditions: this.liveConfig.auto_mute,
|
|
}),
|
|
...(this.liveConfig?.auto_unmute && {
|
|
autoUnmuteConditions: this.liveConfig.auto_unmute,
|
|
}),
|
|
...(this.liveConfig && {
|
|
microphoneMuteSeconds:
|
|
this.liveConfig.microphone.mute_after_microphone_mute_seconds,
|
|
}),
|
|
});
|
|
}
|
|
if (changedProps.has('microphoneState') && this.microphoneState) {
|
|
this._mediaActionsController.setMicrophoneState(this.microphoneState);
|
|
}
|
|
if (
|
|
changedProps.has('call') ||
|
|
changedProps.has('viewManagerEpoch') ||
|
|
changedProps.has('viewFilterCameraID')
|
|
) {
|
|
// Scope the call-answered signal to the carousel that owns the call: in
|
|
// grid mode every carousel receives `.call`, but only the call camera's
|
|
// audio should be acted on. Gating on `answered` (not mere presence)
|
|
// keeps `live.auto_unmute: ['call']` from unmuting the camera's audio
|
|
// during the pre-answer ringing state.
|
|
this._mediaActionsController.setCallAnswered(
|
|
this.call?.cameraID === this._getCarouselCameraID() && !!this.call?.answered,
|
|
);
|
|
}
|
|
}
|
|
|
|
private _getSlides(): TemplateResult[] {
|
|
if (!this.cameraManager) {
|
|
return [];
|
|
}
|
|
|
|
const cameraIDs = this.viewFilterCameraID
|
|
? new Set([this.viewFilterCameraID])
|
|
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
|
|
|
const slides: TemplateResult[] = [];
|
|
for (const cameraID of cameraIDs ?? []) {
|
|
const slide = this._renderLive(cameraID);
|
|
if (slide) {
|
|
slides.push(slide);
|
|
}
|
|
}
|
|
return slides;
|
|
}
|
|
|
|
private _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
|
|
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
|
if (cameraIDs?.size && ev.detail.index !== this._getSelectedCameraIndex()) {
|
|
this._setViewCameraID([...cameraIDs][ev.detail.index]);
|
|
}
|
|
}
|
|
|
|
private _setViewCameraID(cameraID?: string | null): void {
|
|
if (cameraID) {
|
|
void this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({
|
|
params: {
|
|
camera: cameraID,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private _renderLive(cameraID: string): TemplateResult | void {
|
|
const view = this.viewManagerEpoch?.manager.getView();
|
|
|
|
// Resolve substream INTERNALLY: the substream is a playback-layer concern
|
|
// (which `Camera` to actually stream), invisible above the provider. The
|
|
// base `cameraID` flows up as the targetID; the substream `Camera` flows
|
|
// down for playback.
|
|
const resolvedCamera = this.cameraManager
|
|
?.getStore()
|
|
.getCamera(this._getSubstreamCameraID(cameraID, view));
|
|
if (!this.liveConfig || !this.hass || !this.cameraManager || !resolvedCamera) {
|
|
return;
|
|
}
|
|
|
|
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
|
|
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
|
|
|
|
const isSelectedSlide = !!view?.camera && cameraID === view.camera;
|
|
return html`
|
|
<div class="embla__slide">
|
|
${keyed(
|
|
mediaEpoch,
|
|
html`<advanced-camera-card-live-provider
|
|
.camera=${resolvedCamera}
|
|
.targetID=${cameraID}
|
|
.cameraTitle=${cameraMetadata?.title}
|
|
.liveConfig=${this.liveConfig}
|
|
.hass=${this.hass}
|
|
.stateWatcher=${this.stateWatcher}
|
|
.cardWideConfig=${this.cardWideConfig}
|
|
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
|
|
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
|
|
.forceSelected=${isSelectedSlide}
|
|
.locked=${this.locked}
|
|
.suppressLoadingImage=${mediaEpoch > 0}
|
|
@advanced-camera-card:zoom:change=${(
|
|
ev: CustomEvent<ZoomSettingsObserved>,
|
|
) =>
|
|
handleZoomSettingsObservedEvent(
|
|
ev,
|
|
this.viewManagerEpoch?.manager,
|
|
cameraID,
|
|
)}
|
|
>
|
|
</advanced-camera-card-live-provider>`,
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private _getSubstreamCameraID(cameraID: string, view?: View | null): string {
|
|
return view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
|
}
|
|
|
|
private _toggleMute(): void {
|
|
const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController;
|
|
// Fire-and-forget; the `volumechange` event drives the re-render.
|
|
if (controller?.isMuted()) {
|
|
void controller.unmute();
|
|
} else {
|
|
void controller?.mute();
|
|
}
|
|
}
|
|
|
|
private _getCameraNeighbors(): CameraNeighbors | null {
|
|
const cameraIDs = this.cameraManager
|
|
? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')]
|
|
: [];
|
|
const view = this.viewManagerEpoch?.manager.getView();
|
|
|
|
if (this.viewFilterCameraID || cameraIDs.length <= 1 || !view || !this.hass) {
|
|
return {};
|
|
}
|
|
|
|
const cameraID = this.viewFilterCameraID ?? view.camera;
|
|
if (!cameraID) {
|
|
return {};
|
|
}
|
|
const currentIndex = cameraIDs.indexOf(cameraID);
|
|
|
|
if (currentIndex < 0) {
|
|
return {};
|
|
}
|
|
const prevID = cameraIDs[currentIndex > 0 ? currentIndex - 1 : cameraIDs.length - 1];
|
|
const nextID = cameraIDs[currentIndex + 1 < cameraIDs.length ? currentIndex + 1 : 0];
|
|
|
|
return {
|
|
previous: {
|
|
id: prevID,
|
|
metadata: prevID
|
|
? this.cameraManager?.getCameraMetadata(
|
|
this._getSubstreamCameraID(prevID, view),
|
|
)
|
|
: null,
|
|
},
|
|
next: {
|
|
id: nextID,
|
|
metadata: nextID
|
|
? this.cameraManager?.getCameraMetadata(
|
|
this._getSubstreamCameraID(nextID, view),
|
|
)
|
|
: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
private _renderNextPrevious(
|
|
side: 'left' | 'right',
|
|
neighbors: CameraNeighbors | null,
|
|
): TemplateResult {
|
|
const textDirection = getTextDirection(this);
|
|
const neighbor =
|
|
(textDirection === 'ltr' && side === 'left') ||
|
|
(textDirection === 'rtl' && side === 'right')
|
|
? neighbors?.previous
|
|
: neighbors?.next;
|
|
|
|
return html`<advanced-camera-card-next-previous-control
|
|
slot=${side}
|
|
.hass=${this.hass}
|
|
.side=${side}
|
|
.controlConfig=${this.liveConfig?.controls.next_previous}
|
|
.label=${neighbor?.metadata?.title ?? ''}
|
|
.icon=${neighbor?.metadata?.icon}
|
|
.autoHideState=${resolveAutoHideState(!!this.call)}
|
|
?disabled=${!neighbor}
|
|
?locked=${!!this.locked}
|
|
@click=${(ev: Event) => {
|
|
this._setViewCameraID(neighbor?.id);
|
|
stopEventFromActivatingCardWideActions(ev);
|
|
}}
|
|
>
|
|
</advanced-camera-card-next-previous-control>`;
|
|
}
|
|
|
|
protected render(): TemplateResult | void {
|
|
const view = this.viewManagerEpoch?.manager.getView();
|
|
if (!this.liveConfig || !this.hass || !view || !this.cameraManager) {
|
|
return;
|
|
}
|
|
|
|
const slides = this._getSlides();
|
|
if (!slides.length) {
|
|
return;
|
|
}
|
|
|
|
const hasMultipleCameras = slides.length > 1;
|
|
const neighbors = this._getCameraNeighbors();
|
|
|
|
const carouselCameraID = this._getCarouselCameraID();
|
|
const streamAwareCameraID = getStreamCameraID(view, this.viewFilterCameraID);
|
|
const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID);
|
|
|
|
const forcePTZVisibility =
|
|
!this._mediaLoadedInfoSinkController.has() ||
|
|
carouselCameraID !== view.camera ||
|
|
view.context?.ptzControls?.enabled === false
|
|
? false
|
|
: view.context?.ptzControls?.enabled;
|
|
|
|
const dragEnabled =
|
|
hasMultipleCameras &&
|
|
this.liveConfig?.draggable &&
|
|
!gesturesPTZActive &&
|
|
!this.locked;
|
|
|
|
const isCallControlsActive =
|
|
this.liveConfig.controls.call.enabled && this.call?.cameraID === carouselCameraID;
|
|
const callMediaPlayerController =
|
|
this._mediaLoadedInfoSinkController.get()?.mediaPlayerController ?? null;
|
|
|
|
return html`
|
|
<advanced-camera-card-carousel
|
|
${ref(this._refCarousel)}
|
|
.loop=${hasMultipleCameras}
|
|
.dragEnabled=${dragEnabled}
|
|
.selected=${this._getSelectedCameraIndex()}
|
|
.wheelScrolling=${this.liveConfig?.controls.wheel}
|
|
transitionEffect=${this._getTransitionEffect()}
|
|
@advanced-camera-card:carousel:select=${this._setViewHandler.bind(this)}
|
|
@advanced-camera-card:media:volumechange=${() =>
|
|
// Re-render so the call-controls are updated.
|
|
this.requestUpdate()}
|
|
>
|
|
${this._renderNextPrevious('left', neighbors)}
|
|
<!-- -->
|
|
${slides}
|
|
<!-- -->
|
|
${this._renderNextPrevious('right', neighbors)}
|
|
</advanced-camera-card-carousel>
|
|
<div class="status-glow status-glow--trigger"></div>
|
|
<div class="status-glow status-glow--transmitting"></div>
|
|
<advanced-camera-card-ptz
|
|
.hass=${this.hass}
|
|
.config=${this.liveConfig.controls.ptz}
|
|
.cameraManager=${this.cameraManager}
|
|
.cameraID=${streamAwareCameraID}
|
|
.forceVisibility=${forcePTZVisibility}
|
|
.type=${this._getDisplayPTZType(streamAwareCameraID)}
|
|
>
|
|
</advanced-camera-card-ptz>
|
|
<advanced-camera-card-call-controls
|
|
.active=${isCallControlsActive}
|
|
.answered=${this.call?.answered ?? true}
|
|
.microphoneState=${this.microphoneState}
|
|
.muted=${callMediaPlayerController?.isMuted()}
|
|
.buttonSize=${this.liveConfig.controls.call.button_size}
|
|
@advanced-camera-card:call:mute-toggle=${() => this._toggleMute()}
|
|
>
|
|
</advanced-camera-card-call-controls>
|
|
`;
|
|
}
|
|
|
|
private _setMediaTarget(): void {
|
|
const view = this.viewManagerEpoch?.manager.getView();
|
|
const selectedCameraIndex = this._getSelectedCameraIndex();
|
|
|
|
if (this.viewFilterCameraID) {
|
|
void this._mediaActionsController.setTarget(
|
|
selectedCameraIndex,
|
|
// Camera in this carousel is only selected if the camera from the
|
|
// view matches the filtered camera.
|
|
view?.camera === this.viewFilterCameraID,
|
|
);
|
|
} else {
|
|
// Carousel is not filtered, so the targeted camera is always selected.
|
|
void this._mediaActionsController.setTarget(selectedCameraIndex, true);
|
|
}
|
|
|
|
this._mediaHeightController.setSelected(selectedCameraIndex);
|
|
}
|
|
|
|
public updated(changedProperties: PropertyValues): void {
|
|
super.updated(changedProperties);
|
|
|
|
const rootChanged = this._refCarousel.value
|
|
? this._mediaActionsController.setRoot(this._refCarousel.value)
|
|
: false;
|
|
|
|
// If the view has changed, or if the media actions controller has just been
|
|
// initialized, then call the necessary media action.
|
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1626
|
|
if (rootChanged || changedProperties.has('viewManagerEpoch')) {
|
|
this._setMediaTarget();
|
|
}
|
|
|
|
const carouselEl = this._refCarousel.value;
|
|
const view = this.viewManagerEpoch?.manager.getView();
|
|
const streamAwareCameraID = view
|
|
? getStreamCameraID(view, this.viewFilterCameraID)
|
|
: null;
|
|
|
|
if (this._isGesturesPTZActive(view, streamAwareCameraID) && carouselEl) {
|
|
this._ptzDragController.activateIfNecessary(carouselEl);
|
|
} else {
|
|
this._ptzDragController.deactivateIfNecessary();
|
|
}
|
|
}
|
|
|
|
static get styles(): CSSResultGroup {
|
|
return unsafeCSS(liveCarouselStyle);
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'advanced-camera-card-live-carousel': AdvancedCameraCardLiveCarousel;
|
|
}
|
|
}
|