Play audio in the bottom bar media player (#11413)

Co-authored-by: Zack <zackbarett@hey.com>
This commit is contained in:
Paulus Schoutsen 2022-01-24 09:07:47 -08:00 committed by GitHub
parent 416e2e26c0
commit bbcec38450
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 301 additions and 144 deletions

15
src/data/media_source.ts Normal file
View File

@ -0,0 +1,15 @@
import { HomeAssistant } from "../types";
export interface ResolvedMediaSource {
url: string;
mime_type: string;
}
export const resolveMediaSource = (
hass: HomeAssistant,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
type: "media_source/resolve_media",
media_content_id,
});

View File

@ -0,0 +1,106 @@
import {
BROWSER_PLAYER,
MediaPlayerEntity,
MediaPlayerItem,
SUPPORT_PAUSE,
SUPPORT_PLAY,
} from "../../data/media-player";
import { resolveMediaSource } from "../../data/media_source";
import { HomeAssistant } from "../../types";
export class BrowserMediaPlayer {
private player?: HTMLAudioElement;
private stopped = false;
constructor(
public hass: HomeAssistant,
private item: MediaPlayerItem,
private onChange: () => void
) {}
public async initialize() {
const resolvedUrl: any = await resolveMediaSource(
this.hass,
this.item.media_content_id
);
const player = new Audio(resolvedUrl.url);
player.addEventListener("play", this._handleChange);
player.addEventListener("playing", this._handleChange);
player.addEventListener("pause", this._handleChange);
player.addEventListener("ended", this._handleChange);
player.addEventListener("canplaythrough", () => {
if (this.stopped) {
return;
}
this.player = player;
player.play();
this.onChange();
});
}
private _handleChange = () => {
if (!this.stopped) {
this.onChange();
}
};
public pause() {
if (this.player) {
this.player.pause();
}
}
public play() {
if (this.player) {
this.player.play();
}
}
public stop() {
this.stopped = true;
// @ts-ignore
this.onChange = undefined;
if (this.player) {
this.player.pause();
}
}
public get isPlaying(): boolean {
return (
this.player !== undefined && !this.player.paused && !this.player.ended
);
}
static idleStateObj(): MediaPlayerEntity {
const now = new Date().toISOString();
return {
state: "idle",
entity_id: BROWSER_PLAYER,
last_changed: now,
last_updated: now,
attributes: {},
context: { id: "", user_id: null },
};
}
toStateObj(): MediaPlayerEntity {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement
const base = BrowserMediaPlayer.idleStateObj();
if (!this.player) {
return base;
}
base.state = this.isPlaying ? "playing" : "paused";
base.attributes = {
media_title: this.item.title,
media_duration: this.player.duration,
media_position: this.player.currentTime,
media_position_updated_at: base.last_updated,
entity_picture: this.item.thumbnail,
// eslint-disable-next-line no-bitwise
supported_features: SUPPORT_PLAY | SUPPORT_PAUSE,
};
return base;
}
}

View File

@ -36,6 +36,7 @@ import {
formatMediaTime, formatMediaTime,
getCurrentProgress, getCurrentProgress,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerItem,
SUPPORT_BROWSE_MEDIA, SUPPORT_BROWSE_MEDIA,
SUPPORT_PAUSE, SUPPORT_PAUSE,
SUPPORT_PLAY, SUPPORT_PLAY,
@ -43,6 +44,7 @@ import {
} from "../../data/media-player"; } from "../../data/media-player";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../lovelace/components/hui-marquee"; import "../lovelace/components/hui-marquee";
import { BrowserMediaPlayer } from "./browser-media-player";
@customElement("ha-bar-media-player") @customElement("ha-bar-media-player")
class BarMediaPlayer extends LitElement { class BarMediaPlayer extends LitElement {
@ -59,6 +61,8 @@ class BarMediaPlayer extends LitElement {
@state() private _marqueeActive = false; @state() private _marqueeActive = false;
@state() private _browserPlayer?: BrowserMediaPlayer;
private _progressInterval?: number; private _progressInterval?: number;
public connectedCallback(): void { public connectedCallback(): void {
@ -87,73 +91,28 @@ class BarMediaPlayer extends LitElement {
clearInterval(this._progressInterval); clearInterval(this._progressInterval);
this._progressInterval = undefined; this._progressInterval = undefined;
} }
if (this._browserPlayer) {
this._browserPlayer.stop();
this._browserPlayer = undefined;
}
}
public async playItem(item: MediaPlayerItem) {
if (this.entityId !== BROWSER_PLAYER) {
throw Error("Only browser supported");
}
if (this._browserPlayer) {
this._browserPlayer.stop();
}
this._browserPlayer = new BrowserMediaPlayer(this.hass, item, () =>
this.requestUpdate("_browserPlayer")
);
await this._browserPlayer.initialize();
} }
protected render(): TemplateResult { protected render(): TemplateResult {
const choosePlayerElement = html` const isBrowser = this.entityId === BROWSER_PLAYER;
<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 stateObj = this._stateObj;
const controls = !this.narrow const controls = !this.narrow
? computeMediaControls(stateObj) ? computeMediaControls(stateObj)
@ -188,13 +147,13 @@ class BarMediaPlayer extends LitElement {
const mediaDuration = formatMediaTime(stateObj!.attributes.media_duration!); const mediaDuration = formatMediaTime(stateObj!.attributes.media_duration!);
const mediaTitleClean = cleanupMediaTitle(stateObj.attributes.media_title); const mediaTitleClean = cleanupMediaTitle(stateObj.attributes.media_title);
const mediaArt =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
return html` return html`
<div class="info"> <div class="info">
${this._image ${mediaArt ? html`<img src=${this.hass.hassUrl(mediaArt)} />` : ""}
? html`<img src=${this.hass.hassUrl(this._image)} />`
: stateObj.state === "off" || stateObj.state !== "playing"
? html`<div class="blank-image"></div>`
: ""}
<div class="media-info"> <div class="media-info">
<hui-marquee <hui-marquee
.text=${mediaTitleClean || .text=${mediaTitleClean ||
@ -211,19 +170,21 @@ class BarMediaPlayer extends LitElement {
</div> </div>
<div class="controls-progress"> <div class="controls-progress">
<div class="controls"> <div class="controls">
${controls!.map( ${controls === undefined
(control) => html` ? ""
<ha-icon-button : controls.map(
.label=${this.hass.localize( (control) => html`
`ui.card.media_player.${control.action}` <ha-icon-button
)} .label=${this.hass.localize(
.path=${control.icon} `ui.card.media_player.${control.action}`
action=${control.action} )}
@click=${this._handleClick} .path=${control.icon}
> action=${control.action}
</ha-icon-button> @click=${this._handleClick}
` >
)} </ha-icon-button>
`
)}
</div> </div>
${this.narrow ${this.narrow
? html`<mwc-linear-progress></mwc-linear-progress>` ? html`<mwc-linear-progress></mwc-linear-progress>`
@ -235,13 +196,85 @@ class BarMediaPlayer extends LitElement {
</div> </div>
`} `}
</div> </div>
${choosePlayerElement} <div class="choose-player ${isBrowser ? "browser" : ""}">
<ha-button-menu corner="BOTTOM_START">
${this.narrow
? html`
<ha-icon-button
slot="trigger"
.path=${isBrowser
? mdiMonitor
: domainIcon(computeDomain(this.entityId), stateObj)}
></ha-icon-button>
`
: html`
<mwc-button
slot="trigger"
.label=${this.narrow
? ""
: `${computeStateName(stateObj)}
`}
>
<ha-svg-icon
slot="icon"
.path=${isBrowser
? mdiMonitor
: domainIcon(computeDomain(this.entityId), stateObj)}
></ha-svg-icon>
<ha-svg-icon
slot="trailingIcon"
.path=${mdiChevronDown}
></ha-svg-icon>
</mwc-button>
`}
<mwc-list-item
.player=${BROWSER_PLAYER}
?selected=${isBrowser}
@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>
`; `;
} }
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (
changedProps.has("entityId") &&
this.entityId !== BROWSER_PLAYER &&
this._browserPlayer
) {
this._browserPlayer?.stop();
this._browserPlayer = undefined;
}
}
protected updated(changedProps: PropertyValues) { protected updated(changedProps: PropertyValues) {
if (!this.hass || !this._stateObj || !changedProps.has("hass")) { super.updated(changedProps);
return;
if (this.entityId === BROWSER_PLAYER) {
if (!changedProps.has("_browserPlayer")) {
return;
}
} else {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && oldHass.states[this.entityId] === this._stateObj) {
return;
}
} }
const stateObj = this._stateObj; const stateObj = this._stateObj;
@ -266,8 +299,14 @@ class BarMediaPlayer extends LitElement {
} }
} }
private get _stateObj(): MediaPlayerEntity | undefined { private get _stateObj(): MediaPlayerEntity {
return this.hass!.states[this.entityId] as MediaPlayerEntity; if (this._browserPlayer) {
return this._browserPlayer.toStateObj();
}
return (
(this.hass!.states[this.entityId] as MediaPlayerEntity | undefined) ||
BrowserMediaPlayer.idleStateObj()
);
} }
private get _showProgressBar() { private get _showProgressBar() {
@ -277,10 +316,6 @@ class BarMediaPlayer extends LitElement {
const stateObj = this._stateObj; const stateObj = this._stateObj;
if (!stateObj) {
return false;
}
return ( return (
(stateObj.state === "playing" || stateObj.state === "paused") && (stateObj.state === "playing" || stateObj.state === "paused") &&
"media_duration" in stateObj.attributes && "media_duration" in stateObj.attributes &&
@ -288,53 +323,48 @@ class BarMediaPlayer extends LitElement {
); );
} }
private get _image() { private get _mediaPlayerEntities() {
if (!this.hass) { return Object.values(this.hass!.states).filter(
return undefined; (entity) =>
} computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, SUPPORT_BROWSE_MEDIA)
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 { private _updateProgressBar(): void {
if (this._progressBar && this._stateObj?.attributes.media_duration) { if (!this._progressBar || !this._currentProgress) {
const currentProgress = getCurrentProgress(this._stateObj); return;
this._progressBar.progress = }
currentProgress / this._stateObj!.attributes.media_duration;
if (this._currentProgress) { if (!this._stateObj.attributes.media_duration) {
this._currentProgress.innerHTML = formatMediaTime(currentProgress); this._progressBar.progress = 0;
} this._currentProgress.innerHTML = "";
return;
}
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 { private _handleClick(e: MouseEvent): void {
const action = (e.currentTarget! as HTMLElement).getAttribute("action")!; const action = (e.currentTarget! as HTMLElement).getAttribute("action")!;
this.hass!.callService("media_player", action, {
entity_id: this.entityId, if (!this._browserPlayer) {
}); this.hass!.callService("media_player", action, {
entity_id: this.entityId,
});
return;
}
if (action === "media_pause") {
this._browserPlayer.pause();
} else if (action === "media_play") {
this._browserPlayer.play();
}
} }
private _marqueeMouseOver(): void { private _marqueeMouseOver(): void {
@ -401,6 +431,10 @@ class BarMediaPlayer extends LitElement {
padding: 16px; padding: 16px;
} }
.controls {
height: 48px;
}
.controls-progress { .controls-progress {
flex: 2; flex: 2;
display: flex; display: flex;
@ -436,12 +470,6 @@ class BarMediaPlayer extends LitElement {
max-height: 100px; max-height: 100px;
} }
.blank-image {
height: 100px;
width: 100px;
background-color: var(--divider-color);
}
ha-button-menu mwc-button { ha-button-menu mwc-button {
line-height: 1; line-height: 1;
} }
@ -487,6 +515,10 @@ class BarMediaPlayer extends LitElement {
top: -4px; top: -4px;
left: 0; left: 0;
} }
mwc-list-item[selected] {
font-weight: bold;
}
`; `;
} }
} }

View File

@ -16,6 +16,7 @@ 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 type { MediaPlayerItemId } from "../../components/media-player/ha-media-player-browse";
import { BROWSER_PLAYER, MediaPickedEvent } from "../../data/media-player"; import { BROWSER_PLAYER, MediaPickedEvent } from "../../data/media-player";
import { resolveMediaSource } from "../../data/media_source";
import "../../layouts/ha-app-layout"; import "../../layouts/ha-app-layout";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types"; import type { HomeAssistant, Route } from "../../types";
@ -131,25 +132,28 @@ class PanelMediaBrowser extends LitElement {
ev: HASSDomEvent<MediaPickedEvent> ev: HASSDomEvent<MediaPickedEvent>
): Promise<void> { ): Promise<void> {
const item = ev.detail.item; const item = ev.detail.item;
if (this._entityId === BROWSER_PLAYER) { if (this._entityId !== BROWSER_PLAYER) {
const resolvedUrl: any = await this.hass.callWS({ this.hass!.callService("media_player", "play_media", {
type: "media_source/resolve_media", entity_id: this._entityId,
media_content_id: item.media_content_id, media_content_id: item.media_content_id,
media_content_type: item.media_content_type,
}); });
} else if (item.media_content_type.startsWith("audio/")) {
await this.shadowRoot!.querySelector("ha-bar-media-player")!.playItem(
item
);
} else {
const resolvedUrl: any = await resolveMediaSource(
this.hass,
item.media_content_id
);
showWebBrowserPlayMediaDialog(this, { showWebBrowserPlayMediaDialog(this, {
sourceUrl: resolvedUrl.url, sourceUrl: resolvedUrl.url,
sourceType: resolvedUrl.mime_type, sourceType: resolvedUrl.mime_type,
title: item.title, title: item.title,
}); });
return;
} }
this.hass!.callService("media_player", "play_media", {
entity_id: this._entityId,
media_content_id: item.media_content_id,
media_content_type: item.media_content_type,
});
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {