Media player playback controls card feature (#26608)

* Setup

* Add buttons

* Fix

* Move to function

* Clean

* Cleanup

* Check client size

* Get width from host component

* Fix

* Spacing

* use current target

* Ensure state updates update render

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/panels/lovelace/card-features/hui-media-player-playback-card-feature.ts

* Resolve code suggestion type errors

* Resize observer not needed

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aidan Timson
2025-08-21 11:28:43 +01:00
committed by GitHub
parent 11d32300e9
commit 4960284e2d
5 changed files with 336 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
mdiPause,
mdiPlay,
mdiPlayPause,
mdiPower,
mdiSkipNext,
mdiSkipPrevious,
mdiStop,
} from "@mdi/js";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
LovelaceCardFeatureContext,
MediaPlayerPlaybackCardFeatureConfig,
} from "./types";
import type {
ControlButton,
MediaPlayerEntity,
} from "../../../data/media-player";
import { MediaPlayerEntityFeature } from "../../../data/media-player";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { stateActive } from "../../../common/entity/state_active";
import { isUnavailableState } from "../../../data/entity";
import { hasConfigChanged } from "../common/has-changed";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon";
export const supportsMediaPlayerPlaybackCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return domain === "media_player";
};
@customElement("hui-media-player-playback-card-feature")
class HuiMediaPlayerPlaybackCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ attribute: false }) public color?: string;
@state() private _config?: MediaPlayerPlaybackCardFeatureConfig;
@state() private _narrow?: boolean = false;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id] as
| MediaPlayerEntity
| undefined;
}
static getStubConfig(): MediaPlayerPlaybackCardFeatureConfig {
return {
type: "media-player-playback",
};
}
public setConfig(config: MediaPlayerPlaybackCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public willUpdate(): void {
if (!this.hasUpdated) {
this._measureCard();
}
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const entityId = this.context?.entity_id;
return (
hasConfigChanged(this, changedProps) ||
(changedProps.has("hass") &&
(!oldHass ||
!entityId ||
oldHass.states[entityId] !== this.hass!.states[entityId]))
);
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!supportsMediaPlayerPlaybackCardFeature(this.hass, this.context) ||
!this._stateObj
) {
return nothing;
}
const buttons = this._computeButtons(this._stateObj);
return html`
<ha-control-button-group>
${supportsFeature(this._stateObj, MediaPlayerEntityFeature.TURN_OFF) &&
stateActive(this._stateObj)
? html`
<ha-control-button
.label=${this.hass.localize("ui.card.media_player.turn_off")}
@click=${this._togglePower}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-control-button>
`
: ""}
${supportsFeature(this._stateObj, MediaPlayerEntityFeature.TURN_ON) &&
!stateActive(this._stateObj) &&
!isUnavailableState(this._stateObj.state)
? html`
<ha-control-button
.label=${this.hass.localize("ui.card.media_player.turn_on")}
@click=${this._togglePower}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-control-button>
`
: buttons.map(
(button) => html`
<ha-control-button
key=${button.action}
.label=${this.hass?.localize(
`ui.card.media_player.${button.action}`
)}
@click=${this._action}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`
)}
</ha-control-button-group>
`;
}
private _measureCard() {
if (!this.isConnected) {
return;
}
const host = (this.getRootNode() as ShadowRoot).host as
| HTMLElement
| undefined;
const width = host?.clientWidth ?? this.clientWidth ?? 0;
this._narrow = width < 300;
}
private _computeControlButton(stateObj: MediaPlayerEntity): ControlButton {
return stateObj.state === "on"
? { icon: mdiPlayPause, action: "media_play_pause" }
: stateObj.state !== "playing"
? { icon: mdiPlay, action: "media_play" }
: supportsFeature(stateObj, MediaPlayerEntityFeature.PAUSE)
? { icon: mdiPause, action: "media_pause" }
: { icon: mdiStop, action: "media_stop" };
}
private _computeButtons(stateObj: MediaPlayerEntity): ControlButton[] {
const controlButton = this._computeControlButton(stateObj);
const assumedState = stateObj.attributes.assumed_state === true;
const controls: ControlButton[] = [];
if (
!this._narrow &&
(stateObj.state === "playing" || assumedState) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.PREVIOUS_TRACK)
) {
controls.push({ icon: mdiSkipPrevious, action: "media_previous_track" });
}
if (
!assumedState &&
((stateObj.state === "playing" &&
(supportsFeature(stateObj, MediaPlayerEntityFeature.PAUSE) ||
supportsFeature(stateObj, MediaPlayerEntityFeature.STOP))) ||
((stateObj.state === "paused" || stateObj.state === "idle") &&
supportsFeature(stateObj, MediaPlayerEntityFeature.PLAY)) ||
(stateObj.state === "on" &&
(supportsFeature(stateObj, MediaPlayerEntityFeature.PLAY) ||
supportsFeature(stateObj, MediaPlayerEntityFeature.PAUSE))))
) {
controls.push({ icon: controlButton.icon, action: controlButton.action });
}
if (assumedState) {
if (supportsFeature(stateObj, MediaPlayerEntityFeature.PLAY)) {
controls.push({ icon: mdiPlay, action: "media_play" });
}
if (supportsFeature(stateObj, MediaPlayerEntityFeature.PAUSE)) {
controls.push({ icon: mdiPause, action: "media_pause" });
}
if (supportsFeature(stateObj, MediaPlayerEntityFeature.STOP)) {
controls.push({ icon: mdiStop, action: "media_stop" });
}
}
if (
(stateObj.state === "playing" || assumedState) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.NEXT_TRACK)
) {
controls.push({ icon: mdiSkipNext, action: "media_next_track" });
}
return controls;
}
private _togglePower(): void {
if (!this._stateObj) return;
this.hass!.callService(
"media_player",
stateActive(this._stateObj) ? "turn_off" : "turn_on",
{
entity_id: this._stateObj.entity_id,
}
);
}
private _action(e: Event): void {
const action = (e.currentTarget as HTMLElement).getAttribute("key");
if (!action) return;
switch (action) {
case "media_play_pause":
this._playPauseStop();
break;
case "media_play":
this._play();
break;
case "media_pause":
this._pause();
break;
case "media_stop":
this._stop();
break;
case "media_previous_track":
this._previousTrack();
break;
case "media_next_track":
this._nextTrack();
break;
}
}
private _playPauseStop(): void {
if (!this._stateObj) return;
const service =
this._stateObj.state !== "playing"
? "media_play"
: supportsFeature(this._stateObj, MediaPlayerEntityFeature.PAUSE)
? "media_pause"
: "media_stop";
this.hass!.callService("media_player", service, {
entity_id: this._stateObj.entity_id,
});
}
private _play(): void {
if (!this._stateObj) return;
this.hass!.callService("media_player", "media_play", {
entity_id: this._stateObj.entity_id,
});
}
private _pause(): void {
if (!this._stateObj) return;
this.hass!.callService("media_player", "media_pause", {
entity_id: this._stateObj.entity_id,
});
}
private _stop(): void {
if (!this._stateObj) return;
this.hass!.callService("media_player", "media_stop", {
entity_id: this._stateObj.entity_id,
});
}
private _previousTrack(): void {
if (!this._stateObj) return;
this.hass!.callService("media_player", "media_previous_track", {
entity_id: this._stateObj.entity_id,
});
}
private _nextTrack(): void {
if (!this._stateObj) return;
this.hass!.callService("media_player", "media_next_track", {
entity_id: this._stateObj.entity_id,
});
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-media-player-playback-card-feature": HuiMediaPlayerPlaybackCardFeature;
}
}

View File

@@ -39,6 +39,10 @@ export interface LockOpenDoorCardFeatureConfig {
type: "lock-open-door";
}
export interface MediaPlayerPlaybackCardFeatureConfig {
type: "media-player-playback";
}
export interface MediaPlayerVolumeSliderCardFeatureConfig {
type: "media-player-volume-slider";
}
@@ -242,6 +246,7 @@ export type LovelaceCardFeatureConfig =
| LightColorTempCardFeatureConfig
| LockCommandsCardFeatureConfig
| LockOpenDoorCardFeatureConfig
| MediaPlayerPlaybackCardFeatureConfig
| MediaPlayerVolumeSliderCardFeatureConfig
| NumericInputCardFeatureConfig
| SelectOptionsCardFeatureConfig

View File

@@ -22,6 +22,7 @@ import "../card-features/hui-light-brightness-card-feature";
import "../card-features/hui-light-color-temp-card-feature";
import "../card-features/hui-lock-commands-card-feature";
import "../card-features/hui-lock-open-door-card-feature";
import "../card-features/hui-media-player-playback-card-feature";
import "../card-features/hui-media-player-volume-slider-card-feature";
import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
@@ -70,6 +71,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"light-color-temp",
"lock-commands",
"lock-open-door",
"media-player-playback",
"media-player-volume-slider",
"numeric-input",
"select-options",

View File

@@ -42,6 +42,7 @@ import { supportsLightBrightnessCardFeature } from "../../card-features/hui-ligh
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature";
import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature";
import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature";
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
@@ -95,6 +96,7 @@ const UI_FEATURE_TYPES = [
"light-color-temp",
"lock-commands",
"lock-open-door",
"media-player-playback",
"media-player-volume-slider",
"numeric-input",
"select-options",
@@ -162,6 +164,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"light-color-temp": supportsLightColorTempCardFeature,
"lock-commands": supportsLockCommandsCardFeature,
"lock-open-door": supportsLockOpenDoorCardFeature,
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"select-options": supportsSelectOptionsCardFeature,

View File

@@ -7905,6 +7905,9 @@
"lock-open-door": {
"label": "Lock open door"
},
"media-player-playback": {
"label": "Media player playback controls"
},
"media-player-volume-slider": {
"label": "Media player volume slider"
},