Reflect media browser panel state in URL (#11317)

This commit is contained in:
Paulus Schoutsen 2022-01-17 07:40:51 -08:00 committed by GitHub
parent 32bbdc194a
commit 09a27a6791
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 222 additions and 150 deletions

View File

@ -9,32 +9,30 @@ import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-dialog"; import "../ha-dialog";
import "./ha-media-player-browse"; import "./ha-media-player-browse";
import type { MediaPlayerItemId } from "./ha-media-player-browse";
import { MediaPlayerBrowseDialogParams } from "./show-media-browser-dialog"; import { MediaPlayerBrowseDialogParams } from "./show-media-browser-dialog";
@customElement("dialog-media-player-browse") @customElement("dialog-media-player-browse")
class DialogMediaPlayerBrowse extends LitElement { class DialogMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _entityId!: string; @state() private _navigateIds?: MediaPlayerItemId[];
@state() private _mediaContentId?: string;
@state() private _mediaContentType?: string;
@state() private _action?: MediaPlayerBrowseAction;
@state() private _params?: MediaPlayerBrowseDialogParams; @state() private _params?: MediaPlayerBrowseDialogParams;
public showDialog(params: MediaPlayerBrowseDialogParams): void { public showDialog(params: MediaPlayerBrowseDialogParams): void {
this._params = params; this._params = params;
this._entityId = this._params.entityId; this._navigateIds = [
this._mediaContentId = this._params.mediaContentId; {
this._mediaContentType = this._params.mediaContentType; media_content_id: this._params.mediaContentId,
this._action = this._params.action || "play"; media_content_type: this._params.mediaContentType,
},
];
} }
public closeDialog() { public closeDialog() {
this._params = undefined; this._params = undefined;
this._navigateIds = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@ -55,17 +53,21 @@ class DialogMediaPlayerBrowse extends LitElement {
<ha-media-player-browse <ha-media-player-browse
dialog dialog
.hass=${this.hass} .hass=${this.hass}
.entityId=${this._entityId} .entityId=${this._params.entityId}
.action=${this._action!} .navigateIds=${this._navigateIds}
.mediaContentId=${this._mediaContentId} .action=${this._action}
.mediaContentType=${this._mediaContentType}
@close-dialog=${this.closeDialog} @close-dialog=${this.closeDialog}
@media-picked=${this._mediaPicked} @media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
></ha-media-player-browse> ></ha-media-player-browse>
</ha-dialog> </ha-dialog>
`; `;
} }
private _mediaBrowsed(ev) {
this._navigateIds = ev.detail.ids;
}
private _mediaPicked(ev: HASSDomEvent<MediaPickedEvent>): void { private _mediaPicked(ev: HASSDomEvent<MediaPickedEvent>): void {
this._params!.mediaPickedCallback(ev.detail); this._params!.mediaPickedCallback(ev.detail);
if (this._action !== "play") { if (this._action !== "play") {
@ -73,6 +75,10 @@ class DialogMediaPlayerBrowse extends LitElement {
} }
} }
private get _action(): MediaPlayerBrowseAction {
return this._params!.action || "play";
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,

View File

@ -53,19 +53,21 @@ import "../ha-svg-icon";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
"media-picked": MediaPickedEvent; "media-picked": MediaPickedEvent;
"media-browsed": { ids: MediaPlayerItemId[]; back?: boolean };
} }
} }
export interface MediaPlayerItemId {
media_content_id: string | undefined;
media_content_type: string | undefined;
}
@customElement("ha-media-player-browse") @customElement("ha-media-player-browse")
export class HaMediaPlayerBrowse extends LitElement { export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public entityId!: string; @property() public entityId!: string;
@property() public mediaContentId?: string;
@property() public mediaContentType?: string;
@property() public action: MediaPlayerBrowseAction = "play"; @property() public action: MediaPlayerBrowseAction = "play";
@property({ type: Boolean }) public dialog = false; @property({ type: Boolean }) public dialog = false;
@ -76,11 +78,13 @@ export class HaMediaPlayerBrowse extends LitElement {
@property({ type: Boolean, attribute: "scroll", reflect: true }) @property({ type: Boolean, attribute: "scroll", reflect: true })
private _scrolled = false; private _scrolled = false;
@state() private _loading = false; @property() public navigateIds!: MediaPlayerItemId[];
@state() private _error?: { message: string; code: string }; @state() private _error?: { message: string; code: string };
@state() private _mediaPlayerItems: MediaPlayerItem[] = []; @state() private _parentItem?: MediaPlayerItem;
@state() private _currentItem?: MediaPlayerItem;
@query(".header") private _header?: HTMLDivElement; @query(".header") private _header?: HTMLDivElement;
@ -109,47 +113,18 @@ export class HaMediaPlayerBrowse extends LitElement {
} }
} }
public navigateBack() {
this._mediaPlayerItems!.pop();
const item = this._mediaPlayerItems!.pop();
if (!item) {
return;
}
this._navigate(item);
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (this._loading) { if (this._error) {
return html`
<div class="container">${this._renderError(this._error)}</div>
`;
}
if (!this._currentItem) {
return html`<ha-circular-progress active></ha-circular-progress>`; return html`<ha-circular-progress active></ha-circular-progress>`;
} }
if (this._error && !this._mediaPlayerItems.length) { const currentItem = this._currentItem;
if (this.dialog) {
this._closeDialogAction();
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.media-browser.media_browsing_error"
),
text: this._renderError(this._error),
});
} else {
return html`
<div class="container">${this._renderError(this._error)}</div>
`;
}
}
if (!this._mediaPlayerItems.length) {
return html``;
}
const currentItem =
this._mediaPlayerItems[this._mediaPlayerItems.length - 1];
const previousItem: MediaPlayerItem | undefined =
this._mediaPlayerItems.length > 1
? this._mediaPlayerItems[this._mediaPlayerItems.length - 2]
: undefined;
const subtitle = this.hass.localize( const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}` `ui.components.media-browser.class.${currentItem.media_class}`
@ -202,11 +177,11 @@ export class HaMediaPlayerBrowse extends LitElement {
: html``} : html``}
<div class="header-info"> <div class="header-info">
<div class="breadcrumb"> <div class="breadcrumb">
${previousItem ${this.navigateIds.length > 1
? html` ? html`
<div class="previous-title" @click=${this.navigateBack}> <div class="previous-title" @click=${this.navigateBack}>
<ha-svg-icon .path=${mdiArrowLeft}></ha-svg-icon> <ha-svg-icon .path=${mdiArrowLeft}></ha-svg-icon>
${previousItem.title} ${this._parentItem ? this._parentItem.title : ""}
</div> </div>
` `
: ""} : ""}
@ -401,49 +376,115 @@ export class HaMediaPlayerBrowse extends LitElement {
this._attachResizeObserver(); this._attachResizeObserver();
} }
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.size > 1 || !changedProps.has("hass")) {
return true;
}
const oldHass = changedProps.get("hass") as this["hass"];
return oldHass === undefined || oldHass.localize !== this.hass.localize;
}
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entityId")) {
this._setError(undefined);
}
if (!changedProps.has("navigateIds")) {
return;
}
const oldNavigateIds = changedProps.get("navigateIds") as
| this["navigateIds"]
| undefined;
// We're navigating. Reset the shizzle.
this._content?.scrollTo(0, 0);
this._scrolled = false;
const oldCurrentItem = this._currentItem;
const oldParentItem = this._parentItem;
this._currentItem = undefined;
this._parentItem = undefined;
const currentId = this.navigateIds[this.navigateIds.length - 1];
const parentId =
this.navigateIds.length > 1
? this.navigateIds[this.navigateIds.length - 2]
: undefined;
let currentProm: Promise<MediaPlayerItem> | undefined;
let parentProm: Promise<MediaPlayerItem> | undefined;
// See if we can take loading shortcuts if navigating to parent or child
if (
// Check if we navigated to a child
oldNavigateIds &&
this.navigateIds.length > oldNavigateIds.length &&
oldNavigateIds.every((oldVal, idx) => {
const curVal = this.navigateIds[idx];
return (
curVal.media_content_id === oldVal.media_content_id &&
curVal.media_content_type === oldVal.media_content_type
);
})
) {
parentProm = Promise.resolve(oldCurrentItem!);
} else if (
// Check if we navigated to a parent
oldNavigateIds &&
this.navigateIds.length < oldNavigateIds.length &&
this.navigateIds.every((curVal, idx) => {
const oldVal = oldNavigateIds[idx];
return (
curVal.media_content_id === oldVal.media_content_id &&
curVal.media_content_type === oldVal.media_content_type
);
})
) {
currentProm = Promise.resolve(oldParentItem!);
}
// Fetch current
if (!currentProm) {
currentProm = this._fetchData(
this.entityId,
currentId.media_content_id,
currentId.media_content_type
);
}
currentProm.then(
(item) => {
this._currentItem = item;
},
(err) => this._setError(err)
);
// Fetch parent
if (!parentProm && parentId !== undefined) {
parentProm = this._fetchData(
this.entityId,
parentId.media_content_id,
parentId.media_content_type
);
}
if (parentProm) {
parentProm.then((parent) => {
this._parentItem = parent;
});
}
}
protected updated(changedProps: PropertyValues): void { protected updated(changedProps: PropertyValues): void {
super.updated(changedProps); super.updated(changedProps);
if ( if (changedProps.has("_scrolled")) {
changedProps.has("_mediaPlayerItems") && this._animateHeaderHeight();
this._mediaPlayerItems.length } else if (changedProps.has("_currentItem")) {
) {
this._setHeaderHeight(); this._setHeaderHeight();
this._attachIntersectionObserver(); this._attachIntersectionObserver();
} }
}
if ( private navigateBack() {
changedProps.get("_scrolled") !== undefined && fireEvent(this, "media-browsed", {
this._mediaPlayerItems.length ids: this.navigateIds.slice(0, -1),
) { back: true,
this._animateHeaderHeight(); });
}
if (
!changedProps.has("entityId") &&
!changedProps.has("mediaContentId") &&
!changedProps.has("mediaContentType") &&
!changedProps.has("action")
) {
return;
}
if (changedProps.has("entityId")) {
this._error = undefined;
this._mediaPlayerItems = [];
}
this._fetchData(this.mediaContentId, this.mediaContentType)
.then((itemData) => {
if (!itemData) {
return;
}
this._mediaPlayerItems = [itemData];
})
.catch((err) => {
this._error = err;
});
} }
private async _setHeaderHeight() { private async _setHeaderHeight() {
@ -497,54 +538,19 @@ export class HaMediaPlayerBrowse extends LitElement {
return; return;
} }
this._navigate(item); fireEvent(this, "media-browsed", {
} ids: [...this.navigateIds, item],
});
private async _navigate(item: MediaPlayerItem) {
this._error = undefined;
let itemData: MediaPlayerItem;
try {
itemData = await this._fetchData(
item.media_content_id,
item.media_content_type
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.media-browser.media_browsing_error"
),
text: this._renderError(err),
});
return;
}
this._content?.scrollTo(0, 0);
this._scrolled = false;
this._mediaPlayerItems = [...this._mediaPlayerItems, itemData];
} }
private async _fetchData( private async _fetchData(
entityId: string,
mediaContentId?: string, mediaContentId?: string,
mediaContentType?: string mediaContentType?: string
): Promise<MediaPlayerItem> { ): Promise<MediaPlayerItem> {
this._loading = true; return entityId !== BROWSER_PLAYER
let itemData: any; ? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
try { : browseLocalMediaPlayer(this.hass, mediaContentId);
itemData =
this.entityId !== BROWSER_PLAYER
? await browseMediaPlayer(
this.hass,
this.entityId,
mediaContentId,
mediaContentType
)
: await browseLocalMediaPlayer(this.hass, mediaContentId);
} finally {
this._loading = false;
}
return itemData;
} }
private _measureCard(): void { private _measureCard(): void {
@ -576,7 +582,7 @@ export class HaMediaPlayerBrowse extends LitElement {
* Load thumbnails for images on demand as they become visible. * Load thumbnails for images on demand as they become visible.
*/ */
private async _attachIntersectionObserver(): Promise<void> { private async _attachIntersectionObserver(): Promise<void> {
if (!this._thumbnails) { if (!("IntersectionObserver" in window) || !this._thumbnails) {
return; return;
} }
if (!this._intersectionObserver) { if (!this._intersectionObserver) {
@ -605,15 +611,34 @@ export class HaMediaPlayerBrowse extends LitElement {
); );
} }
const observer = this._intersectionObserver!; const observer = this._intersectionObserver!;
this._thumbnails.forEach((thumbnailCard) => { for (const thumbnailCard of this._thumbnails) {
observer.observe(thumbnailCard); observer.observe(thumbnailCard);
}); }
} }
private _closeDialogAction(): void { private _closeDialogAction(): void {
fireEvent(this, "close-dialog"); fireEvent(this, "close-dialog");
} }
private _setError(error: any) {
if (!this.dialog) {
this._error = error;
return;
}
if (!error) {
return;
}
this._closeDialogAction();
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.media-browser.media_browsing_error"
),
text: this._renderError(error),
});
}
private _renderError(err: { message: string; code: string }) { private _renderError(err: { message: string; code: string }) {
if (err.message === "Media directory does not exist.") { if (err.message === "Media directory does not exist.") {
return html` return html`

View File

@ -21,13 +21,11 @@ import "./home-assistant-main";
const useHash = __DEMO__; const useHash = __DEMO__;
const curPath = () => const curPath = () =>
window.decodeURIComponent( useHash ? location.hash.substring(1) : location.pathname;
useHash ? location.hash.substr(1) : location.pathname
);
const panelUrl = (path: string) => { const panelUrl = (path: string) => {
const dividerPos = path.indexOf("/", 1); const dividerPos = path.indexOf("/", 1);
return dividerPos === -1 ? path.substr(1) : path.substr(1, dividerPos - 1); return dividerPos === -1 ? path.substring(1) : path.substring(1, dividerPos);
}; };
@customElement("home-assistant") @customElement("home-assistant")

View File

@ -16,6 +16,7 @@ import { supportsFeature } from "../../common/entity/supports-feature";
import { navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
import "../../components/ha-menu-button"; import "../../components/ha-menu-button";
import "../../components/media-player/ha-media-player-browse"; import "../../components/media-player/ha-media-player-browse";
import type { MediaPlayerItemId } from "../../components/media-player/ha-media-player-browse";
import { import {
BROWSER_PLAYER, BROWSER_PLAYER,
MediaPickedEvent, MediaPickedEvent,
@ -36,8 +37,14 @@ class PanelMediaBrowser extends LitElement {
@property() public route!: Route; @property() public route!: Route;
// @ts-ignore private _navigateIds: MediaPlayerItemId[] = [
@LocalStorage("mediaBrowseEntityId", true) {
media_content_id: undefined,
media_content_type: undefined,
},
];
@LocalStorage("mediaBrowseEntityId")
private _entityId = BROWSER_PLAYER; private _entityId = BROWSER_PLAYER;
protected render(): TemplateResult { protected render(): TemplateResult {
@ -77,15 +84,17 @@ class PanelMediaBrowser extends LitElement {
<ha-media-player-browse <ha-media-player-browse
.hass=${this.hass} .hass=${this.hass}
.entityId=${this._entityId} .entityId=${this._entityId}
.navigateIds=${this._navigateIds}
@media-picked=${this._mediaPicked} @media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
></ha-media-player-browse> ></ha-media-player-browse>
</div> </div>
</ha-app-layout> </ha-app-layout>
`; `;
} }
public updated(changedProps: PropertyValues): void { public willUpdate(changedProps: PropertyValues): void {
super.updated(changedProps); super.willUpdate(changedProps);
if (!changedProps.has("route")) { if (!changedProps.has("route")) {
return; return;
@ -96,10 +105,28 @@ class PanelMediaBrowser extends LitElement {
return; return;
} }
const routePlayer = this.route.path.substring(1).split("/")[0]; const [routePlayer, ...navigateIdsEncoded] = this.route.path
.substring(1)
.split("/");
if (routePlayer !== this._entityId) { if (routePlayer !== this._entityId) {
this._entityId = routePlayer; this._entityId = routePlayer;
} }
this._navigateIds = [
{
media_content_type: undefined,
media_content_id: undefined,
},
...navigateIdsEncoded.map((navigateId) => {
const [media_content_type, media_content_id] =
decodeURIComponent(navigateId).split(",");
return {
media_content_type,
media_content_id,
};
}),
];
} }
private _showSelectMediaPlayerDialog(): void { private _showSelectMediaPlayerDialog(): void {
@ -111,6 +138,22 @@ class PanelMediaBrowser extends LitElement {
}); });
} }
private _mediaBrowsed(ev) {
if (ev.detail.back) {
history.back();
return;
}
let path = "";
for (const item of ev.detail.ids.slice(1)) {
path +=
"/" +
encodeURIComponent(
`${item.media_content_type},${item.media_content_id}`
);
}
navigate(`/media-browser/${this._entityId}${path}`);
}
private async _mediaPicked( private async _mediaPicked(
ev: HASSDomEvent<MediaPickedEvent> ev: HASSDomEvent<MediaPickedEvent>
): Promise<void> { ): Promise<void> {