mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 11:46:42 +00:00
Play audio in the bottom bar media player (#11413)
Co-authored-by: Zack <zackbarett@hey.com>
This commit is contained in:
parent
416e2e26c0
commit
bbcec38450
15
src/data/media_source.ts
Normal file
15
src/data/media_source.ts
Normal 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,
|
||||
});
|
106
src/panels/media-browser/browser-media-player.ts
Normal file
106
src/panels/media-browser/browser-media-player.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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`
|
||||
<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 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`
|
||||
<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>`
|
||||
: ""}
|
||||
${mediaArt ? html`<img src=${this.hass.hassUrl(mediaArt)} />` : ""}
|
||||
<div class="media-info">
|
||||
<hui-marquee
|
||||
.text=${mediaTitleClean ||
|
||||
@ -211,19 +170,21 @@ class BarMediaPlayer extends LitElement {
|
||||
</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>
|
||||
`
|
||||
)}
|
||||
${controls === undefined
|
||||
? ""
|
||||
: 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>`
|
||||
@ -235,13 +196,85 @@ class BarMediaPlayer extends LitElement {
|
||||
</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) {
|
||||
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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
@ -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<MediaPickedEvent>
|
||||
): Promise<void> {
|
||||
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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user