feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+97 -59
View File
@@ -16,6 +16,7 @@ import { MediaActionsController } from '../../components-lib/media-actions-contr
import { TransitionEffect } from '../../config/schema/common/transition-effect.js';
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
@@ -26,9 +27,9 @@ import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
import { getTextDirection } from '../../utils/text-direction.js';
import { ViewMedia } from '../../view/media.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewMedia } from '../../view/item.js';
import '../carousel';
import type { EmblaCarouselPlugins } from '../carousel.js';
import { renderMessage } from '../message.js';
@@ -80,33 +81,13 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
public showControls = true;
@state()
protected _selected = 0;
protected _selected: number | null = null;
protected _media: ViewMedia[] | null = null;
protected _mediaActionsController = new MediaActionsController();
protected _loadedMediaPlayerController: MediaPlayerController | null = null;
protected _refCarousel: Ref<HTMLElement> = createRef();
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('viewManagerEpoch')) {
// Seek into the video if the seek time has changed (this is also called
// on media load, since the media may or may not have been loaded at
// this point).
if (
this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !==
this.viewManagerEpoch?.oldView?.context?.mediaViewer
) {
this._seekHandler();
}
}
if (this._refCarousel.value) {
this._mediaActionsController.setRoot(this._refCarousel.value);
}
}
public connectedCallback(): void {
super.connectedCallback();
@@ -145,7 +126,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
*/
protected _getMediaNeighbors(): MediaNeighbors | null {
const mediaCount = this._media?.length ?? 0;
if (!this._media) {
if (!this._media || this._selected === null) {
return null;
}
@@ -180,16 +161,20 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
// index).
return;
}
const newResults = view?.queryResults
?.clone()
.selectIndex(index, this.viewFilterCameraID);
.selectResultIfFound((item) => item === this._media?.[index], {
main: true,
cameraID: this.viewFilterCameraID,
});
if (!newResults) {
return;
}
const cameraID = newResults
.getSelectedResult(this.viewFilterCameraID)
?.getCameraID();
const selectedItem = newResults.getSelectedResult(this.viewFilterCameraID);
const cameraID = ViewItemClassifier.isMedia(selectedItem)
? selectedItem.getCameraID()
: null;
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
@@ -243,33 +228,43 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
}
if (changedProps.has('viewManagerEpoch')) {
const view = this.viewManagerEpoch?.manager.getView();
const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
const newSelected =
view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
const newSeek = view?.context?.mediaViewer?.seek;
const newView = this.viewManagerEpoch?.manager.getView();
if (newMedia !== this._media || newSelected !== this._selected || !newSeek) {
if (!newView?.context?.mediaViewer?.seek) {
setOrRemoveAttribute(this, false, 'unseekable');
this._media = newMedia;
this._selected = newSelected;
}
if (!newMedia?.length) {
// No media will be rendered.
this._mediaActionsController.unsetTarget();
} else {
if (this.viewFilterCameraID) {
this._mediaActionsController.setTarget(
newSelected,
// 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.
this._mediaActionsController.setTarget(newSelected, true);
}
const oldView = this.viewManagerEpoch?.oldView;
const oldItems =
oldView?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
const newItems =
newView?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
let resetMedia = false;
if (!this._media || oldItems !== newItems) {
this._media =
newItems?.filter((item) => ViewItemClassifier.isMedia(item)) ?? null;
resetMedia = true;
}
const oldSelectedItem = oldView?.queryResults?.getSelectedResult(
this.viewFilterCameraID,
);
const newSelectedItem = newView?.queryResults?.getSelectedResult(
this.viewFilterCameraID,
);
// _selected is an index, it needs to be updated if either the selected
// item or the media changes.
if (oldSelectedItem !== newSelectedItem || resetMedia) {
const newSelected =
this._media?.findIndex((item) => item === newSelectedItem) ?? null;
// If there's no selected item, just choose the last (most recent one) to
// avoid rendering a blank. This could happen if the selected item was a
// folder.
this._selected =
newSelected ??
(this._media && this._media.length ? this._media.length - 1 : null);
}
}
}
@@ -327,12 +322,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
});
}
// If there's no selected media, just choose the last (most recent one) to
// avoid rendering a blank. This situation should not occur in practice, as
// this view should not be called without a selected media.
const selectedMedia = this._media[this._selected] ?? this._media[mediaCount - 1];
if (!this.hass || !this.cameraManager || !selectedMedia) {
if (!this.hass || !this.cameraManager || this._selected === null) {
return;
}
@@ -379,13 +369,61 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
`;
}
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();
}
if (changedProperties.has('viewManagerEpoch')) {
// Seek into the video if the seek time has changed (this is also called
// on media load, since the media may or may not have been loaded at
// this point).
if (
this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !==
this.viewManagerEpoch?.oldView?.context?.mediaViewer
) {
this._seekHandler();
}
}
}
protected _setMediaTarget(): void {
if (!this._media?.length || this._selected === null) {
this._mediaActionsController.unsetTarget();
} else {
this._mediaActionsController.setTarget(
this._selected,
// Camera in this carousel is only selected if the camera from the view
// matches the filtered camera.
this.viewFilterCameraID
? this.viewManagerEpoch?.manager.getView()?.camera === this.viewFilterCameraID
: true,
);
}
}
/**
* Fire a media show event when a slide is selected.
*/
protected async _seekHandler(): Promise<void> {
const view = this.viewManagerEpoch?.manager.getView();
const seek = view?.context?.mediaViewer?.seek;
if (!this.hass || !seek || !this._media || !this._loadedMediaPlayerController) {
if (
!this.hass ||
!seek ||
!this._media ||
!this._loadedMediaPlayerController ||
this._selected === null
) {
return;
}
const selectedMedia = this._media[this._selected];
+1 -1
View File
@@ -14,9 +14,9 @@ import { MediaGridSelected } from '../../components-lib/media-grid-controller.js
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { HomeAssistant } from '../../ha/types.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import '../../patches/ha-hls-player.js';
import basicBlockStyle from '../../scss/basic-block.scss';
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
import './carousel';
@customElement('advanced-camera-card-viewer-grid')
+23 -3
View File
@@ -1,14 +1,22 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
import viewerStyle from '../../scss/viewer.scss';
import { ResolvedMediaCache } from '../../utils/ha/resolved-media.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { renderMessage } from '../message.js';
import './grid';
@@ -42,6 +50,18 @@ export class AdvancedCameraCardViewer extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: 'empty', reflect: true, type: Boolean })
public isEmpty = false;
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('viewManagerEpoch')) {
const view = this.viewManagerEpoch?.manager.getView();
this.isEmpty = !view?.queryResults
?.getResults()
?.filter((result) => ViewItemClassifier.isMedia(result)).length;
}
}
protected render(): TemplateResult | void {
if (
!this.hass ||
@@ -53,7 +73,7 @@ export class AdvancedCameraCardViewer extends LitElement {
return;
}
if (!this.viewManagerEpoch.manager.getView()?.queryResults?.hasResults()) {
if (this.isEmpty) {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user pans into an area with no
// media.
+22 -26
View File
@@ -16,31 +16,24 @@ import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { ViewerConfig } from '../../config/schema/viewer.js';
import { HomeAssistant } from '../../ha/types.js';
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import {
MediaPlayer,
MediaPlayerController,
MediaPlayerElement,
ResolvedMedia,
} from '../../types.js';
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
import {
canonicalizeHAURL,
homeAssistantSignPath,
isHARelativeURL,
} from '../../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media.js';
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
import { homeAssistantSignPath } from '../../ha/sign-path.js';
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
import {
addDynamicProxyURL,
getWebProxiedURL,
shouldUseWebProxy,
} from '../../utils/ha/web-proxy.js';
} from '../../ha/web-proxy.js';
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { ViewMediaClassifier } from '../../view/media-classifier.js';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/media.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { QueryClassifier } from '../../view/query-classifier.js';
import '../image-player.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
@@ -93,8 +86,8 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
!this.media ||
// If this specific media item has no clip, then do nothing (even if all
// the other media items do).
!ViewMediaClassifier.isEvent(this.media) ||
!MediaQueriesClassifier.areEventQueries(view.query)
!ViewItemClassifier.isEvent(this.media) ||
!QueryClassifier.isEventQuery(view.query)
) {
return;
}
@@ -103,7 +96,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
const clipQuery = view.query.clone();
clipQuery.convertToClipsQueries();
const queries = clipQuery.getQueries();
const queries = clipQuery.getQuery();
if (!queries) {
return;
}
@@ -154,7 +147,8 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
return;
}
const camera = this.cameraManager?.getStore().getCamera(this.media.getCameraID());
const cameraID = this.media.getCameraID();
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
const proxyConfig = camera?.getProxyConfig();
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) {
@@ -224,7 +218,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
const cameraID = this.media.getCameraID();
const mediaID = this.media.getID() ?? undefined;
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
const cameraConfig = cameraID
? this.cameraManager?.getStore().getCameraConfig(cameraID) ?? null
: null;
const view = this.viewManagerEpoch?.manager.getView();
return this.viewerConfig?.zoomable
@@ -269,7 +265,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
// Note: crossorigin="anonymous" is required on <video> below in order to
// allow screenshot of motionEye videos which currently go cross-origin.
return this._useZoomIfRequired(html`
${ViewMediaClassifier.isVideo(this.media)
${ViewItemClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
? html`<advanced-camera-card-ha-hls-player
${ref(this._refProvider)}