Add support for grouping media players (#24736)

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
pull/25592/head
Alex Gustafsson 2025-05-26 16:35:09 +02:00 committed by GitHub
parent fcf5ed7731
commit 6c7d750734
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 498 additions and 30 deletions

View File

@ -0,0 +1,228 @@
import type { CSSResultGroup } from "lit";
import { mdiClose } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { fireEvent } from "../../common/dom/fire_event";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-dialog";
import "../ha-button";
import "../ha-dialog-header";
import "./ha-media-player-toggle";
import type { JoinMediaPlayersDialogParams } from "./show-join-media-players-dialog";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
type MediaPlayerEntity,
MediaPlayerEntityFeature,
mediaPlayerJoin,
mediaPlayerUnjoin,
} from "../../data/media-player";
import { extractApiErrorMessage } from "../../data/hassio/common";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import { computeDomain } from "../../common/entity/compute_domain";
@customElement("dialog-join-media-players")
class DialogJoinMediaPlayers extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _entityId?: string;
@state() private _groupMembers!: string[];
@state() private _selectedEntities!: string[];
@state() private _submitting?: boolean;
@state() private _error?: string;
public showDialog(params: JoinMediaPlayersDialogParams): void {
this._entityId = params.entityId;
const stateObj = this.hass.states[params.entityId] as
| MediaPlayerEntity
| undefined;
this._groupMembers =
stateObj?.attributes.group_members?.filter(
(entityId) => entityId !== params.entityId
) || [];
this._selectedEntities = this._groupMembers;
}
public closeDialog() {
this._entityId = undefined;
this._selectedEntities = [];
this._groupMembers = [];
this._submitting = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._entityId) {
return nothing;
}
const entityId = this._entityId;
const stateObj = this.hass.states[entityId] as HassEntity | undefined;
const name = (stateObj && computeStateName(stateObj)) || entityId;
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
flexContent
.heading=${name}
@closed=${this.closeDialog}
>
<ha-dialog-header show-border slot="heading">
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
dialogAction="close"
slot="navigationIcon"
></ha-icon-button>
<span slot="title"
>${this.hass.localize("ui.card.media_player.media_players")}</span
>
<ha-button slot="actionItems" @click=${this._selectAll}>
${this.hass.localize("ui.card.media_player.select_all")}
</ha-button>
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="content">
<ha-media-player-toggle
.hass=${this.hass}
.entityId=${entityId}
checked
disabled
></ha-media-player-toggle>
${this._mediaPlayerEntities(this.hass.entities).map(
(entity) =>
html`<ha-media-player-toggle
.hass=${this.hass}
.entityId=${entity.entity_id}
.checked=${this._selectedEntities.includes(entity.entity_id)}
@change=${this._handleSelectedChange}
></ha-media-player-toggle>`
)}
</div>
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
.disabled=${this._submitting}
slot="primaryAction"
@click=${this._submit}
>
${this.hass.localize("ui.common.apply")}
</ha-button>
</ha-dialog>
`;
}
private _mediaPlayerEntities = (
entities: Record<string, EntityRegistryDisplayEntry>
) => {
if (!this._entityId) {
return [];
}
const currentPlatform = this.hass.entities[this._entityId]?.platform;
if (!currentPlatform) {
return [];
}
return Object.values(entities).filter((entity) => {
if (entity.entity_id === this._entityId) {
return false;
}
if (computeDomain(entity.entity_id) !== "media_player") {
return false;
}
if (this.hass.entities[entity.entity_id]?.platform !== currentPlatform) {
return false;
}
if (
!this.hass.states[entity.entity_id] ||
!supportsFeature(
this.hass.states[entity.entity_id],
MediaPlayerEntityFeature.GROUPING
)
) {
return false;
}
return true;
});
};
private _selectAll() {
this._selectedEntities = this._mediaPlayerEntities(this.hass.entities).map(
(entity) => entity.entity_id
);
}
private _handleSelectedChange(ev) {
const selectedEntities = this._selectedEntities.filter(
(entityId) => entityId !== ev.target.entityId
);
if (ev.target.checked) {
selectedEntities.push(ev.target.entityId);
}
this._selectedEntities = selectedEntities;
}
private async _submit(): Promise<void> {
if (!this._entityId) {
return;
}
this._error = undefined;
this._submitting = true;
try {
// If media is already playing
await mediaPlayerJoin(this.hass, this._entityId, this._selectedEntities);
await Promise.all(
this._groupMembers
.filter((entityId) => !this._selectedEntities.includes(entityId))
.map((entityId) => mediaPlayerUnjoin(this.hass, entityId))
);
this.closeDialog();
} catch (err) {
this._error = extractApiErrorMessage(err);
}
this._submitting = false;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.content {
display: flex;
flex-direction: column;
row-gap: 16px;
}
ha-dialog-header ha-button {
margin: 6px;
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-join-media-players": DialogJoinMediaPlayers;
}
}

View File

@ -0,0 +1,96 @@
import { type CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiSpeaker } from "@mdi/js";
import type { HomeAssistant } from "../../types";
import { computeStateName } from "../../common/entity/compute_state_name";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-switch";
import "../ha-svg-icon";
import type { MediaPlayerEntity } from "../../data/media-player";
@customElement("ha-media-player-toggle")
class HaMediaPlayerToggle extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ type: Boolean }) public checked = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
const stateObj = this.hass.states[this.entityId];
return html`<div class="list-item">
<ha-svg-icon .path=${mdiSpeaker}></ha-svg-icon>
<div class="info">
<div class="main-text">${computeStateName(stateObj)}</div>
<div class="secondary-text">
${this._formatSecondaryText(stateObj as MediaPlayerEntity)}
</div>
</div>
<ha-switch
.disabled=${this.disabled}
.checked=${this.checked}
@change=${this._handleChange}
></ha-switch>
</div>`;
}
private _formatSecondaryText(stateObj: MediaPlayerEntity): string {
if (stateObj.state !== "playing") {
return this.hass.localize("ui.card.media_player.idle");
}
return [stateObj.attributes.media_title, stateObj.attributes.media_artist]
.filter((segment) => segment)
.join(" · ");
}
static get styles(): CSSResultGroup {
return [
css`
.list-item {
display: grid;
grid-template-columns: auto 1fr auto;
column-gap: 16px;
align-items: center;
width: 100%;
}
.info {
min-width: 0;
}
.main-text {
color: var(--primary-text-color);
}
.main-text[take-height] {
line-height: 40px;
}
.secondary-text {
color: var(--secondary-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
private _handleChange(ev) {
ev.stopPropagation();
this.checked = ev.target.checked;
fireEvent(this, "change");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-toggle": HaMediaPlayerToggle;
}
}

View File

@ -0,0 +1,16 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface JoinMediaPlayersDialogParams {
entityId: string;
}
export const showJoinMediaPlayersDialog = (
element: HTMLElement,
dialogParams: JoinMediaPlayersDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-join-media-players",
dialogImport: () => import("./dialog-join-media-players"),
dialogParams,
});
};

View File

@ -63,6 +63,7 @@ interface MediaPlayerEntityAttributes extends HassEntityAttributeBase {
source_list?: string[];
sound_mode?: string;
sound_mode_list?: string[];
group_members?: string[];
}
export interface MediaPlayerEntity extends HassEntityBase {
@ -510,3 +511,12 @@ export const mediaPlayerPlayMedia = (
...extra,
});
};
export const mediaPlayerJoin = (
hass: HomeAssistant,
entity_id: string,
group_members: string[]
) => hass.callService("media_player", "join", { group_members }, { entity_id });
export const mediaPlayerUnjoin = (hass: HomeAssistant, entity_id: string) =>
hass.callService("media_player", "unjoin", {}, { entity_id });

View File

@ -4,6 +4,7 @@ import {
mdiLoginVariant,
mdiMusicNote,
mdiPlayBoxMultiple,
mdiSpeakerMultiple,
mdiVolumeHigh,
mdiVolumeMinus,
mdiVolumeOff,
@ -20,6 +21,7 @@ import "../../../components/ha-select";
import "../../../components/ha-slider";
import "../../../components/ha-svg-icon";
import { showMediaBrowserDialog } from "../../../components/media-player/show-media-browser-dialog";
import { showJoinMediaPlayersDialog } from "../../../components/media-player/show-join-media-players-dialog";
import { isUnavailableState } from "../../../data/entity";
import type {
MediaPickedEvent,
@ -46,6 +48,7 @@ class MoreInfoMediaPlayer extends LitElement {
const stateObj = this.stateObj;
const controls = computeMediaControls(stateObj, true);
const groupMembers = stateObj.attributes.group_members?.length;
return html`
<div class="controls">
@ -69,7 +72,7 @@ class MoreInfoMediaPlayer extends LitElement {
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)
? html`
<mwc-button
<ha-button
.label=${this.hass.localize(
"ui.card.media_player.browse_media"
)}
@ -79,7 +82,26 @@ class MoreInfoMediaPlayer extends LitElement {
.path=${mdiPlayBoxMultiple}
slot="icon"
></ha-svg-icon>
</mwc-button>
</ha-button>
`
: ""}
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.GROUPING)
? html`
<ha-button
.label=${this.hass.localize("ui.card.media_player.join")}
@click=${this._showGroupMediaPlayers}
>
<ha-svg-icon
.path=${mdiSpeakerMultiple}
slot="icon"
></ha-svg-icon>
${groupMembers && groupMembers > 1
? html`<span class="badge">
${stateObj.attributes.group_members?.length}
</span>`
: nothing}
</ha-button>
`
: ""}
</div>
@ -265,6 +287,23 @@ class MoreInfoMediaPlayer extends LitElement {
mwc-button > ha-svg-icon {
vertical-align: text-bottom;
}
.badge {
position: absolute;
top: 0;
left: 16px;
display: flex;
justify-content: center;
align-items: center;
height: 16px;
min-width: 8px;
border-radius: 10px;
font-weight: var(--ha-font-weight-normal);
font-size: var(--ha-font-size-xs);
background-color: var(--accent-color);
padding: 0 4px;
color: var(--text-accent-color, var(--text-primary-color));
}
`;
private _handleClick(e: MouseEvent): void {
@ -328,6 +367,12 @@ class MoreInfoMediaPlayer extends LitElement {
),
});
}
private _showGroupMediaPlayers(): void {
showJoinMediaPlayersDialog(this, {
entityId: this.stateObj!.entity_id,
});
}
}
declare global {

View File

@ -1,6 +1,10 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import type { LinearProgress } from "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiDotsVertical, mdiPlayBoxMultiple } from "@mdi/js";
import {
mdiDotsVertical,
mdiPlayBoxMultiple,
mdiSpeakerMultiple,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@ -38,6 +42,7 @@ import "../components/hui-marquee";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { MediaControlCardConfig } from "./types";
import { showJoinMediaPlayersDialog } from "../../../components/media-player/show-join-media-players-dialog";
@customElement("hui-media-control-card")
export class HuiMediaControlCard extends LitElement implements LovelaceCard {
@ -186,6 +191,8 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
const mediaDescription = computeMediaDescription(stateObj);
const mediaTitleClean = cleanupMediaTitle(stateObj.attributes.media_title);
const groupMembers = stateObj.attributes.group_members?.length;
return html`
<ha-card>
<div
@ -272,34 +279,62 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
? ""
: html`
<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>
`
)}
${supportsFeature(
stateObj,
MediaPlayerEntityFeature.BROWSE_MEDIA
)
? html`
<div class="start">
${controls!.map(
(control) => html`
<ha-icon-button
class="browse-media"
.label=${this.hass.localize(
"ui.card.media_player.browse_media"
`ui.card.media_player.${control.action}`
)}
.path=${mdiPlayBoxMultiple}
@click=${this._handleBrowseMedia}
></ha-icon-button>
.path=${control.icon}
action=${control.action}
@click=${this._handleClick}
>
</ha-icon-button>
`
: ""}
)}
</div>
<div class="end">
${supportsFeature(
stateObj,
MediaPlayerEntityFeature.BROWSE_MEDIA
)
? html`
<ha-icon-button
class="browse-media"
.label=${this.hass.localize(
"ui.card.media_player.browse_media"
)}
.path=${mdiPlayBoxMultiple}
@click=${this._handleBrowseMedia}
></ha-icon-button>
`
: ""}
${supportsFeature(
stateObj,
MediaPlayerEntityFeature.GROUPING
)
? html`
<ha-icon-button
class="join-media"
.label=${this.hass.localize(
"ui.card.media_player.join"
)}
@click=${this._handleJoinMediaPlayers}
>
<ha-svg-icon
.path=${mdiSpeakerMultiple}
></ha-svg-icon>
${groupMembers && groupMembers > 1
? html`<span class="badge">
${stateObj.attributes.group_members
?.length}
</span>`
: nothing}
</ha-icon-button>
`
: ""}
</div>
</div>
`}
</div>
@ -509,6 +544,12 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
});
}
private _handleJoinMediaPlayers(): void {
showJoinMediaPlayersDialog(this, {
entityId: this._config!.entity,
});
}
private _handleClick(e: MouseEvent): void {
handleMediaControlClick(
this.hass!,
@ -700,6 +741,10 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
align-items: center;
}
.controls > .start {
flex-grow: 1;
}
.controls ha-icon-button {
--mdc-icon-button-size: 44px;
--mdc-icon-size: 30px;
@ -716,8 +761,12 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
}
ha-icon-button.browse-media {
position: absolute;
right: 4px;
--mdc-icon-size: 24px;
inset-inline-end: 4px;
inset-inline-start: initial;
}
ha-icon-button.join-media {
--mdc-icon-size: 24px;
inset-inline-end: 4px;
inset-inline-start: initial;
@ -804,6 +853,25 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
.no-progress.player:not(.no-controls) {
padding-bottom: 0px;
}
.badge {
position: absolute;
top: 0;
right: 0;
width: auto;
height: auto;
display: flex;
justify-content: center;
align-items: center;
min-width: 8px;
min-height: 16px;
border-radius: 10px;
font-weight: var(--ha-font-weight-normal);
font-size: var(--ha-font-size-xs);
background-color: var(--accent-color);
padding: 0 4px;
color: var(--text-accent-color, var(--text-primary-color));
}
`;
}

View File

@ -217,7 +217,11 @@
"repeat_set": "Repeat mode",
"shuffle_set": "Shuffle",
"text_to_speak": "Text to speak",
"nothing_playing": "Nothing playing"
"nothing_playing": "Nothing playing",
"join": "Join",
"media_players": "Media players",
"select_all": "Select all",
"idle": "Idle"
},
"persistent_notification": {
"dismiss": "Dismiss"
@ -345,6 +349,7 @@
"undo": "Undo",
"move": "Move",
"save": "Save",
"apply": "Apply",
"add": "Add",
"create": "Create",
"edit": "Edit",