From bbcec38450b90005423b838fb09d5093f39fea95 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jan 2022 09:07:47 -0800 Subject: [PATCH] Play audio in the bottom bar media player (#11413) Co-authored-by: Zack --- src/data/media_source.ts | 15 + .../media-browser/browser-media-player.ts | 106 +++++++ .../media-browser/ha-bar-media-player.ts | 300 ++++++++++-------- .../media-browser/ha-panel-media-browser.ts | 24 +- 4 files changed, 301 insertions(+), 144 deletions(-) create mode 100644 src/data/media_source.ts create mode 100644 src/panels/media-browser/browser-media-player.ts diff --git a/src/data/media_source.ts b/src/data/media_source.ts new file mode 100644 index 0000000000..0b2b70b989 --- /dev/null +++ b/src/data/media_source.ts @@ -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({ + type: "media_source/resolve_media", + media_content_id, + }); diff --git a/src/panels/media-browser/browser-media-player.ts b/src/panels/media-browser/browser-media-player.ts new file mode 100644 index 0000000000..34e78d9069 --- /dev/null +++ b/src/panels/media-browser/browser-media-player.ts @@ -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; + } +} diff --git a/src/panels/media-browser/ha-bar-media-player.ts b/src/panels/media-browser/ha-bar-media-player.ts index 0b60d5eb8b..34c5e77156 100644 --- a/src/panels/media-browser/ha-bar-media-player.ts +++ b/src/panels/media-browser/ha-bar-media-player.ts @@ -36,6 +36,7 @@ import { formatMediaTime, getCurrentProgress, MediaPlayerEntity, + MediaPlayerItem, SUPPORT_BROWSE_MEDIA, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -43,6 +44,7 @@ import { } from "../../data/media-player"; import type { HomeAssistant } from "../../types"; import "../lovelace/components/hui-marquee"; +import { BrowserMediaPlayer } from "./browser-media-player"; @customElement("ha-bar-media-player") class BarMediaPlayer extends LitElement { @@ -59,6 +61,8 @@ class BarMediaPlayer extends LitElement { @state() private _marqueeActive = false; + @state() private _browserPlayer?: BrowserMediaPlayer; + private _progressInterval?: number; public connectedCallback(): void { @@ -87,73 +91,28 @@ class BarMediaPlayer extends LitElement { clearInterval(this._progressInterval); 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 { - const choosePlayerElement = html` -
- - ${this.narrow - ? html` - - ` - : html` - - - - - `} - ${this.hass.localize( - "ui.components.media-browser.web-browser" - )} - ${this._mediaPlayerEntities.map( - (source) => html` - ${computeStateName(source)} - ` - )} - -
- `; - - if (!this._stateObj) { - return choosePlayerElement; - } - + const isBrowser = this.entityId === BROWSER_PLAYER; const stateObj = this._stateObj; const controls = !this.narrow ? computeMediaControls(stateObj) @@ -188,13 +147,13 @@ class BarMediaPlayer extends LitElement { const mediaDuration = formatMediaTime(stateObj!.attributes.media_duration!); const mediaTitleClean = cleanupMediaTitle(stateObj.attributes.media_title); + const mediaArt = + stateObj.attributes.entity_picture_local || + stateObj.attributes.entity_picture; + return html`
- ${this._image - ? html`` - : stateObj.state === "off" || stateObj.state !== "playing" - ? html`
` - : ""} + ${mediaArt ? html`` : ""}
- ${controls!.map( - (control) => html` - - - ` - )} + ${controls === undefined + ? "" + : controls.map( + (control) => html` + + + ` + )}
${this.narrow ? html`` @@ -235,13 +196,85 @@ class BarMediaPlayer extends LitElement {
`}
- ${choosePlayerElement} +
+ + ${this.narrow + ? html` + + ` + : html` + + + + + `} + + ${this.hass.localize("ui.components.media-browser.web-browser")} + + ${this._mediaPlayerEntities.map( + (source) => html` + + ${computeStateName(source)} + + ` + )} + +
`; } + 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) { - if (!this.hass || !this._stateObj || !changedProps.has("hass")) { - return; + super.updated(changedProps); + + 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; @@ -266,8 +299,14 @@ class BarMediaPlayer extends LitElement { } } - private get _stateObj(): MediaPlayerEntity | undefined { - return this.hass!.states[this.entityId] as MediaPlayerEntity; + private get _stateObj(): MediaPlayerEntity { + if (this._browserPlayer) { + return this._browserPlayer.toStateObj(); + } + return ( + (this.hass!.states[this.entityId] as MediaPlayerEntity | undefined) || + BrowserMediaPlayer.idleStateObj() + ); } private get _showProgressBar() { @@ -277,10 +316,6 @@ class BarMediaPlayer extends LitElement { const stateObj = this._stateObj; - if (!stateObj) { - return false; - } - return ( (stateObj.state === "playing" || stateObj.state === "paused") && "media_duration" in stateObj.attributes && @@ -288,53 +323,48 @@ class BarMediaPlayer extends LitElement { ); } - 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) => + computeStateDomain(entity) === "media_player" && + supportsFeature(entity, SUPPORT_BROWSE_MEDIA) ); } - 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._progressBar || !this._currentProgress) { + return; + } - if (this._currentProgress) { - this._currentProgress.innerHTML = formatMediaTime(currentProgress); - } + if (!this._stateObj.attributes.media_duration) { + 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 { 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 { @@ -401,6 +431,10 @@ class BarMediaPlayer extends LitElement { padding: 16px; } + .controls { + height: 48px; + } + .controls-progress { flex: 2; display: flex; @@ -436,12 +470,6 @@ class BarMediaPlayer extends LitElement { max-height: 100px; } - .blank-image { - height: 100px; - width: 100px; - background-color: var(--divider-color); - } - ha-button-menu mwc-button { line-height: 1; } @@ -487,6 +515,10 @@ class BarMediaPlayer extends LitElement { top: -4px; left: 0; } + + mwc-list-item[selected] { + font-weight: bold; + } `; } } diff --git a/src/panels/media-browser/ha-panel-media-browser.ts b/src/panels/media-browser/ha-panel-media-browser.ts index 01c65f3be5..690e97731e 100644 --- a/src/panels/media-browser/ha-panel-media-browser.ts +++ b/src/panels/media-browser/ha-panel-media-browser.ts @@ -16,6 +16,7 @@ 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 } from "../../data/media-player"; +import { resolveMediaSource } from "../../data/media_source"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant, Route } from "../../types"; @@ -131,25 +132,28 @@ class PanelMediaBrowser extends LitElement { ev: HASSDomEvent ): Promise { const item = ev.detail.item; - if (this._entityId === BROWSER_PLAYER) { - const resolvedUrl: any = await this.hass.callWS({ - type: "media_source/resolve_media", + if (this._entityId !== BROWSER_PLAYER) { + 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, }); + } 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, { sourceUrl: resolvedUrl.url, sourceType: resolvedUrl.mime_type, 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 {