Media Browser Bar (#11369)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Zack Barett 2022-01-20 16:37:30 -06:00 committed by GitHub
parent 7ad0b37a9e
commit 303e065433
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 566 additions and 212 deletions

View File

@ -413,32 +413,34 @@ export class HaMediaPlayerBrowse extends LitElement {
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!);
if (!changedProps.has("entityId")) {
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) {
@ -710,7 +712,7 @@ export class HaMediaPlayerBrowse extends LitElement {
right: 0;
left: 0;
z-index: 5;
padding: 20px 24px 10px;
padding: 20px 24px 10px 32px;
}
.header_button {
@ -809,8 +811,7 @@ export class HaMediaPlayerBrowse extends LitElement {
minmax(var(--media-browse-item-size, 175px), 0.1fr)
);
grid-gap: 16px;
padding: 0px 24px;
margin: 8px 0px;
padding: 8px;
}
:host([dialog]) .children {

View File

@ -320,3 +320,16 @@ export const computeMediaControls = (
return buttons.length > 0 ? buttons : undefined;
};
export const formatMediaTime = (seconds: number): string => {
if (!seconds) {
return "";
}
let secondsString = new Date(seconds * 1000).toISOString();
secondsString =
seconds > 3600
? secondsString.substring(11, 16)
: secondsString.substring(14, 19);
return secondsString.replace(/^0+/, "").padStart(4, "0");
};

View File

@ -0,0 +1,496 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-linear-progress/mwc-linear-progress";
import type { LinearProgress } from "@material/mwc-linear-progress/mwc-linear-progress";
import "@material/mwc-list/mwc-list-item";
import {
mdiChevronDown,
mdiMonitor,
mdiPause,
mdiPlay,
mdiPlayPause,
mdiStop,
} from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { domainIcon } from "../../common/entity/domain_icon";
import { supportsFeature } from "../../common/entity/supports-feature";
import { navigate } from "../../common/navigate";
import "../../components/ha-button-menu";
import "../../components/ha-icon-button";
import { UNAVAILABLE_STATES } from "../../data/entity";
import {
BROWSER_PLAYER,
computeMediaControls,
computeMediaDescription,
formatMediaTime,
getCurrentProgress,
MediaPlayerEntity,
SUPPORT_BROWSE_MEDIA,
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_STOP,
} from "../../data/media-player";
import type { HomeAssistant } from "../../types";
import "../lovelace/components/hui-marquee";
@customElement("ha-bar-media-player")
class BarMediaPlayer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ type: Boolean, reflect: true })
public narrow!: boolean;
@query("mwc-linear-progress") private _progressBar?: LinearProgress;
@query("#CurrentProgress") private _currentProgress?: HTMLElement;
@state() private _marqueeActive = false;
private _progressInterval?: number;
public connectedCallback(): void {
super.connectedCallback();
const stateObj = this._stateObj;
if (!stateObj) {
return;
}
if (
!this._progressInterval &&
this._showProgressBar &&
stateObj.state === "playing"
) {
this._progressInterval = window.setInterval(
() => this._updateProgressBar(),
1000
);
}
}
public disconnectedCallback(): void {
if (this._progressInterval) {
clearInterval(this._progressInterval);
this._progressInterval = undefined;
}
}
protected render(): TemplateResult {
const choosePlayerElement = html`
<div
class="choose-player ${this.entityId === BROWSER_PLAYER
? "browser"
: ""}"
>
<ha-button-menu corner="BOTTOM_START">
${this.narrow
? html`
<ha-icon-button
slot="trigger"
.path=${this._stateObj
? domainIcon(computeDomain(this.entityId), this._stateObj)
: mdiMonitor}
></ha-icon-button>
`
: html`
<mwc-button
slot="trigger"
.label=${this.narrow
? ""
: `${
this._stateObj
? computeStateName(this._stateObj)
: BROWSER_PLAYER
}
`}
>
<ha-svg-icon
slot="icon"
.path=${this._stateObj
? domainIcon(computeDomain(this.entityId), this._stateObj)
: mdiMonitor}
></ha-svg-icon>
<ha-svg-icon
slot="trailingIcon"
.path=${mdiChevronDown}
></ha-svg-icon>
</mwc-button>
`}
<mwc-list-item .player=${BROWSER_PLAYER} @click=${this._selectPlayer}
>${this.hass.localize(
"ui.components.media-browser.web-browser"
)}</mwc-list-item
>
${this._mediaPlayerEntities.map(
(source) => html`
<mwc-list-item
?selected=${source.entity_id === this.entityId}
.disabled=${UNAVAILABLE_STATES.includes(source.state)}
.player=${source.entity_id}
@click=${this._selectPlayer}
>${computeStateName(source)}</mwc-list-item
>
`
)}
</ha-button-menu>
</div>
`;
if (!this._stateObj) {
return choosePlayerElement;
}
const stateObj = this._stateObj;
const controls = !this.narrow
? computeMediaControls(stateObj)
: (stateObj.state === "playing" &&
(supportsFeature(stateObj, SUPPORT_PAUSE) ||
supportsFeature(stateObj, SUPPORT_STOP))) ||
((stateObj.state === "paused" || stateObj.state === "idle") &&
supportsFeature(stateObj, SUPPORT_PLAY)) ||
(stateObj.state === "on" &&
(supportsFeature(stateObj, SUPPORT_PLAY) ||
supportsFeature(stateObj, SUPPORT_PAUSE)))
? [
{
icon:
stateObj.state === "on"
? mdiPlayPause
: stateObj.state !== "playing"
? mdiPlay
: supportsFeature(stateObj, SUPPORT_PAUSE)
? mdiPause
: mdiStop,
action:
stateObj.state !== "playing"
? "media_play"
: supportsFeature(stateObj, SUPPORT_PAUSE)
? "media_pause"
: "media_stop",
},
]
: [{}];
const mediaDescription = computeMediaDescription(stateObj);
const mediaDuration = formatMediaTime(stateObj!.attributes.media_duration!);
return html`
<div class="info">
${this._image
? html`<img src=${this.hass.hassUrl(this._image)} />`
: stateObj.state === "off" || stateObj.state !== "playing"
? html`<div class="blank-image"></div>`
: ""}
<div class="media-info">
<hui-marquee
.text=${stateObj.attributes.media_title ||
mediaDescription ||
this.hass.localize(`ui.card.media_player.nothing_playing`)}
.active=${this._marqueeActive}
@mouseover=${this._marqueeMouseOver}
@mouseleave=${this._marqueeMouseLeave}
></hui-marquee>
<span class="secondary">
${stateObj.attributes.media_title ? mediaDescription : ""}
</span>
</div>
</div>
<div class="controls-progress">
<div class="controls">
${controls!.map(
(control) => html`
<ha-icon-button
.label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)}
.path=${control.icon}
action=${control.action}
@click=${this._handleClick}
>
</ha-icon-button>
`
)}
</div>
${this.narrow
? html`<mwc-linear-progress></mwc-linear-progress>`
: html`
<div class="progress">
<div id="CurrentProgress"></div>
<mwc-linear-progress wide></mwc-linear-progress>
<div>${mediaDuration}</div>
</div>
`}
</div>
${choosePlayerElement}
`;
}
protected updated(changedProps: PropertyValues) {
if (!this.hass || !this._stateObj || !changedProps.has("hass")) {
return;
}
const stateObj = this._stateObj;
this._updateProgressBar();
if (
!this._progressInterval &&
this._showProgressBar &&
stateObj.state === "playing"
) {
this._progressInterval = window.setInterval(
() => this._updateProgressBar(),
1000
);
} else if (
this._progressInterval &&
(!this._showProgressBar || stateObj.state !== "playing")
) {
clearInterval(this._progressInterval);
this._progressInterval = undefined;
}
}
private get _stateObj(): MediaPlayerEntity | undefined {
return this.hass!.states[this.entityId] as MediaPlayerEntity;
}
private get _showProgressBar() {
if (!this.hass) {
return false;
}
const stateObj = this._stateObj;
if (!stateObj) {
return false;
}
return (
(stateObj.state === "playing" || stateObj.state === "paused") &&
"media_duration" in stateObj.attributes &&
"media_position" in stateObj.attributes
);
}
private get _image() {
if (!this.hass) {
return undefined;
}
const stateObj = this._stateObj;
if (!stateObj) {
return undefined;
}
return (
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture
);
}
private get _mediaPlayerEntities() {
return Object.values(this.hass!.states).filter((entity) => {
if (
computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, SUPPORT_BROWSE_MEDIA)
) {
return true;
}
return false;
});
}
private _updateProgressBar(): void {
if (this._progressBar && this._stateObj?.attributes.media_duration) {
const currentProgress = getCurrentProgress(this._stateObj);
this._progressBar.progress =
currentProgress / this._stateObj!.attributes.media_duration;
if (this._currentProgress) {
this._currentProgress.innerHTML = formatMediaTime(currentProgress);
}
}
}
private _handleClick(e: MouseEvent): void {
const action = (e.currentTarget! as HTMLElement).getAttribute("action")!;
this.hass!.callService("media_player", action, {
entity_id: this.entityId,
});
}
private _marqueeMouseOver(): void {
if (!this._marqueeActive) {
this._marqueeActive = true;
}
}
private _marqueeMouseLeave(): void {
if (this._marqueeActive) {
this._marqueeActive = false;
}
}
private _selectPlayer(ev: CustomEvent): void {
const entityId = (ev.currentTarget as any).player;
navigate(`/media-browser/${entityId}`, { replace: true });
}
static get styles(): CSSResultGroup {
return css`
:host {
display: flex;
min-height: 100px;
background: var(
--ha-card-background,
var(--card-background-color, white)
);
border-top: 1px solid var(--divider-color);
}
mwc-linear-progress {
width: 100%;
padding: 0 4px;
--mdc-theme-primary: var(--secondary-text-color);
}
mwc-button[slot="trigger"] {
--mdc-theme-primary: var(--primary-text-color);
--mdc-icon-size: 36px;
}
.info {
flex: 1;
display: flex;
align-items: center;
width: 100%;
margin-right: 16px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.secondary,
.progress {
color: var(--secondary-text-color);
}
.choose-player {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px;
}
.controls-progress {
flex: 2;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.progress {
display: flex;
width: 100%;
align-items: center;
}
mwc-linear-progress[wide] {
margin: 0 4px;
}
.media-info {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding-left: 16px;
width: 100%;
}
hui-marquee {
font-size: 1.2em;
margin: 0px 0 4px;
}
img {
max-height: 100px;
}
.blank-image {
height: 100px;
width: 100px;
background-color: var(--divider-color);
}
ha-button-menu mwc-button {
line-height: 1;
}
:host([narrow]) {
min-height: 80px;
max-height: 80px;
}
:host([narrow]) .controls-progress {
flex: unset;
min-width: 48px;
}
:host([narrow]) .controls {
display: flex;
}
:host([narrow]) .choose-player {
padding-left: 0;
min-width: 48px;
flex: unset;
justify-content: center;
}
:host([narrow]) .choose-player.browser {
justify-content: flex-end;
width: 100%;
}
:host([narrow]) img {
max-height: 80px;
}
:host([narrow]) .blank-image {
height: 80px;
width: 80px;
}
:host([narrow]) mwc-linear-progress {
padding: 0;
position: absolute;
top: -4px;
left: 0;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-bar-media-player": BarMediaPlayer;
}
}

View File

@ -11,22 +11,16 @@ import {
import { customElement, property } from "lit/decorators";
import { LocalStorage } from "../../common/decorators/local-storage";
import { HASSDomEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { supportsFeature } from "../../common/entity/supports-feature";
import { navigate } from "../../common/navigate";
import "../../components/ha-menu-button";
import "../../components/media-player/ha-media-player-browse";
import type { MediaPlayerItemId } from "../../components/media-player/ha-media-player-browse";
import {
BROWSER_PLAYER,
MediaPickedEvent,
SUPPORT_BROWSE_MEDIA,
} from "../../data/media-player";
import { BROWSER_PLAYER, MediaPickedEvent } from "../../data/media-player";
import "../../layouts/ha-app-layout";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import "./ha-bar-media-player";
import { showWebBrowserPlayMediaDialog } from "./show-media-player-dialog";
import { showSelectMediaPlayerDialog } from "./show-select-media-source-dialog";
@customElement("ha-panel-media-browser")
class PanelMediaBrowser extends LitElement {
@ -48,17 +42,6 @@ class PanelMediaBrowser extends LitElement {
private _entityId = BROWSER_PLAYER;
protected render(): TemplateResult {
const stateObj = this._entityId
? this.hass.states[this._entityId]
: undefined;
const title =
this._entityId === BROWSER_PLAYER
? `${this.hass.localize("ui.components.media-browser.web-browser")}`
: stateObj?.attributes.friendly_name
? `${stateObj?.attributes.friendly_name}`
: undefined;
return html`
<ha-app-layout>
<app-header fixed slot="header">
@ -73,23 +56,22 @@ class PanelMediaBrowser extends LitElement {
"ui.components.media-browser.media-player-browser"
)}
</div>
<div class="secondary-text">${title || ""}</div>
</div>
<mwc-button @click=${this._showSelectMediaPlayerDialog}>
${this.hass.localize("ui.components.media-browser.choose_player")}
</mwc-button>
</app-toolbar>
</app-header>
<div class="content">
<ha-media-player-browse
.hass=${this.hass}
.entityId=${this._entityId}
.navigateIds=${this._navigateIds}
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
></ha-media-player-browse>
</div>
<ha-media-player-browse
.hass=${this.hass}
.entityId=${this._entityId}
.navigateIds=${this._navigateIds}
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
></ha-media-player-browse>
</ha-app-layout>
<ha-bar-media-player
.hass=${this.hass}
.entityId=${this._entityId}
.narrow=${this.narrow}
></ha-bar-media-player>
`;
}
@ -129,15 +111,6 @@ class PanelMediaBrowser extends LitElement {
];
}
private _showSelectMediaPlayerDialog(): void {
showSelectMediaPlayerDialog(this, {
mediaSources: this._mediaPlayerEntities,
sourceSelectedCallback: (entityId) => {
navigate(`/media-browser/${entityId}`, { replace: true });
},
});
}
private _mediaBrowsed(ev) {
if (ev.detail.back) {
history.back();
@ -179,19 +152,6 @@ class PanelMediaBrowser extends LitElement {
});
}
private get _mediaPlayerEntities() {
return Object.values(this.hass!.states).filter((entity) => {
if (
computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, SUPPORT_BROWSE_MEDIA)
) {
return true;
}
return false;
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@ -199,21 +159,20 @@ class PanelMediaBrowser extends LitElement {
:host {
--mdc-theme-primary: var(--app-header-text-color);
}
ha-media-player-browse {
height: calc(100vh - var(--header-height));
height: calc(100vh - (100px + var(--header-height)));
}
:host([narrow]) app-toolbar mwc-button {
width: 65px;
:host([narrow]) ha-media-player-browse {
height: calc(100vh - (80px + var(--header-height)));
}
.heading {
overflow: hidden;
white-space: nowrap;
margin-top: 4px;
}
.heading .secondary-text {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
ha-bar-media-player {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
`,
];

View File

@ -1,98 +0,0 @@
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { computeStateName } from "../../common/entity/compute_state_name";
import { stringCompare } from "../../common/string/compare";
import { createCloseHeading } from "../../components/ha-dialog";
import { UNAVAILABLE_STATES } from "../../data/entity";
import { BROWSER_PLAYER } from "../../data/media-player";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { SelectMediaPlayerDialogParams } from "./show-select-media-source-dialog";
@customElement("hui-dialog-select-media-player")
export class HuiDialogSelectMediaPlayer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
private _params?: SelectMediaPlayerDialogParams;
public showDialog(params: SelectMediaPlayerDialogParams): void {
this._params = params;
}
public closeDialog() {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
return html`
<ha-dialog
open
hideActions
.heading=${createCloseHeading(
this.hass,
this.hass.localize(`ui.components.media-browser.choose_player`)
)}
@closed=${this.closeDialog}
>
<mwc-list>
<mwc-list-item .player=${BROWSER_PLAYER} @click=${this._selectPlayer}
>${this.hass.localize(
"ui.components.media-browser.web-browser"
)}</mwc-list-item
>
${this._params.mediaSources
.sort((a, b) =>
stringCompare(computeStateName(a), computeStateName(b))
)
.map(
(source) => html`
<mwc-list-item
.disabled=${UNAVAILABLE_STATES.includes(source.state)}
.player=${source.entity_id}
@click=${this._selectPlayer}
>${computeStateName(source)}</mwc-list-item
>
`
)}
</mwc-list>
</ha-dialog>
`;
}
private _selectPlayer(ev: CustomEvent): void {
const entityId = (ev.currentTarget as any).player;
this._params!.sourceSelectedCallback(entityId);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0 24px 20px;
}
mwc-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-select-media-player": HuiDialogSelectMediaPlayer;
}
}

View File

@ -1,18 +0,0 @@
import { HassEntity } from "home-assistant-js-websocket";
import { fireEvent } from "../../common/dom/fire_event";
export interface SelectMediaPlayerDialogParams {
mediaSources: HassEntity[];
sourceSelectedCallback: (entityId: string) => void;
}
export const showSelectMediaPlayerDialog = (
element: HTMLElement,
selectMediaPlayereDialogParams: SelectMediaPlayerDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-select-media-player",
dialogImport: () => import("./hui-dialog-select-media-player"),
dialogParams: selectMediaPlayereDialogParams,
});
};

View File

@ -203,7 +203,8 @@
"media_volume_down": "Volume down",
"media_volume_mute": "Volume mute",
"media_volume_unmute": "Volume unmute",
"text_to_speak": "Text to speak"
"text_to_speak": "Text to speak",
"nothing_playing": "Nothing Playing"
},
"persistent_notification": {
"dismiss": "Dismiss"