diff --git a/src/panels/lovelace/card-features/hui-media-player-playback-card-feature.ts b/src/panels/lovelace/card-features/hui-media-player-playback-card-feature.ts new file mode 100644 index 0000000000..2af417317c --- /dev/null +++ b/src/panels/lovelace/card-features/hui-media-player-playback-card-feature.ts @@ -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` + + ${supportsFeature(this._stateObj, MediaPlayerEntityFeature.TURN_OFF) && + stateActive(this._stateObj) + ? html` + + + + ` + : ""} + ${supportsFeature(this._stateObj, MediaPlayerEntityFeature.TURN_ON) && + !stateActive(this._stateObj) && + !isUnavailableState(this._stateObj.state) + ? html` + + + + ` + : buttons.map( + (button) => html` + + + + ` + )} + + `; + } + + 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; + } +} diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts index fcb0b00190..27b2969703 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -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 diff --git a/src/panels/lovelace/create-element/create-card-feature-element.ts b/src/panels/lovelace/create-element/create-card-feature-element.ts index ebee9dded8..03fa268289 100644 --- a/src/panels/lovelace/create-element/create-card-feature-element.ts +++ b/src/panels/lovelace/create-element/create-card-feature-element.ts @@ -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([ "light-color-temp", "lock-commands", "lock-open-door", + "media-player-playback", "media-player-volume-slider", "numeric-input", "select-options", diff --git a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts index 26ac749f5d..c75c3721ad 100644 --- a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts @@ -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, diff --git a/src/translations/en.json b/src/translations/en.json index 64b91afcf0..1b5ce7fe13 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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" },