mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-03 06:51:48 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c22fe80b06 |
@@ -194,6 +194,7 @@ export interface ControlButton {
|
||||
icon: string;
|
||||
// Used as key for action as well as tooltip and aria-label translation key
|
||||
action: keyof TranslationDict["ui"]["card"]["media_player"];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaPlayerItem {
|
||||
|
||||
@@ -1,81 +1,26 @@
|
||||
import {
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiPlayPause,
|
||||
mdiPowerStandby,
|
||||
mdiPowerOff,
|
||||
mdiPowerOn,
|
||||
mdiRepeat,
|
||||
mdiRepeatOff,
|
||||
mdiRepeatOnce,
|
||||
mdiShuffle,
|
||||
mdiShuffleDisabled,
|
||||
mdiSkipNext,
|
||||
mdiSkipPrevious,
|
||||
mdiStop,
|
||||
mdiVolumeHigh,
|
||||
mdiVolumeMinus,
|
||||
mdiVolumeOff,
|
||||
mdiVolumePlus,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { stateActive } from "../../../common/entity/state_active";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-control-button";
|
||||
import "../../../components/ha-control-button-group";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type {
|
||||
ControlButton,
|
||||
MediaPlayerEntity,
|
||||
} from "../../../data/media-player";
|
||||
import {
|
||||
computeMediaControls,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../../../data/media-player";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { hasConfigChanged } from "../common/has-changed";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import { cardFeatureStyles } from "./common/card-feature-styles";
|
||||
import {
|
||||
MEDIA_PLAYER_PLAYBACK_CONTROLS,
|
||||
type MediaPlayerPlaybackControl,
|
||||
type LovelaceCardFeatureContext,
|
||||
type MediaPlayerPlaybackCardFeatureConfig,
|
||||
computeMediaPlayerPlaybackButtons,
|
||||
getDefaultMediaPlayerControls,
|
||||
} from "./media-player-playback-controls";
|
||||
import type {
|
||||
LovelaceCardFeatureContext,
|
||||
MediaPlayerPlaybackCardFeatureConfig,
|
||||
} from "./types";
|
||||
|
||||
const MEDIA_PLAYER_PLAYBACK_CONTROLS_FEATURES: Record<
|
||||
MediaPlayerPlaybackControl,
|
||||
MediaPlayerEntityFeature[]
|
||||
> = {
|
||||
turn_on: [MediaPlayerEntityFeature.TURN_ON],
|
||||
turn_off: [MediaPlayerEntityFeature.TURN_OFF],
|
||||
media_play: [MediaPlayerEntityFeature.PLAY],
|
||||
media_pause: [MediaPlayerEntityFeature.PAUSE],
|
||||
media_play_pause: [
|
||||
MediaPlayerEntityFeature.PLAY,
|
||||
MediaPlayerEntityFeature.PAUSE,
|
||||
],
|
||||
media_stop: [MediaPlayerEntityFeature.STOP],
|
||||
media_previous_track: [MediaPlayerEntityFeature.PREVIOUS_TRACK],
|
||||
media_next_track: [MediaPlayerEntityFeature.NEXT_TRACK],
|
||||
volume_down: [MediaPlayerEntityFeature.VOLUME_STEP],
|
||||
volume_up: [MediaPlayerEntityFeature.VOLUME_STEP],
|
||||
volume_mute: [MediaPlayerEntityFeature.VOLUME_MUTE],
|
||||
shuffle: [MediaPlayerEntityFeature.SHUFFLE_SET],
|
||||
repeat: [MediaPlayerEntityFeature.REPEAT_SET],
|
||||
};
|
||||
|
||||
export const supportsMediaPlayerPlaybackControl = (
|
||||
stateObj: MediaPlayerEntity,
|
||||
control: MediaPlayerPlaybackControl
|
||||
): boolean =>
|
||||
MEDIA_PLAYER_PLAYBACK_CONTROLS_FEATURES[control].some((feature) =>
|
||||
supportsFeature(stateObj, feature)
|
||||
);
|
||||
|
||||
export const supportsMediaPlayerPlaybackCardFeature = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext
|
||||
@@ -112,12 +57,6 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
| undefined;
|
||||
}
|
||||
|
||||
private get _controls(): MediaPlayerPlaybackControl[] {
|
||||
return this._config?.controls?.length
|
||||
? this._config.controls
|
||||
: [...MEDIA_PLAYER_PLAYBACK_CONTROLS];
|
||||
}
|
||||
|
||||
static getStubConfig(): MediaPlayerPlaybackCardFeatureConfig {
|
||||
return {
|
||||
type: "media-player-playback",
|
||||
@@ -178,6 +117,7 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
.label=${this.hass?.localize(
|
||||
`ui.card.media_player.${button.action}`
|
||||
)}
|
||||
.disabled=${button.disabled}
|
||||
@click=${this._action}
|
||||
>
|
||||
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
|
||||
@@ -200,134 +140,16 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
}
|
||||
|
||||
private _computeButtons(stateObj: MediaPlayerEntity): ControlButton[] {
|
||||
if (this._config?.controls?.length) {
|
||||
return this._filterNarrow(this._computeExplicitButtons(stateObj));
|
||||
}
|
||||
return this._filterNarrow(computeMediaControls(stateObj) ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls are explicitly configured: iterate in config order,
|
||||
* show each supported control as its own button.
|
||||
*/
|
||||
private _computeExplicitButtons(
|
||||
stateObj: MediaPlayerEntity
|
||||
): ControlButton[] {
|
||||
const active = stateActive(stateObj);
|
||||
const assumedState = stateObj.attributes.assumed_state === true;
|
||||
const buttons: ControlButton[] = [];
|
||||
|
||||
for (const control of this._controls) {
|
||||
switch (control) {
|
||||
case "turn_off":
|
||||
if (
|
||||
(active || assumedState) &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)
|
||||
) {
|
||||
buttons.push({
|
||||
icon: assumedState ? mdiPowerOff : mdiPowerStandby,
|
||||
action: "turn_off",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "turn_on":
|
||||
if (
|
||||
(!active || assumedState) &&
|
||||
stateObj.state !== UNAVAILABLE &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
|
||||
) {
|
||||
buttons.push({
|
||||
icon: assumedState ? mdiPowerOn : mdiPowerStandby,
|
||||
action: "turn_on",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "media_play":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.PLAY)) {
|
||||
buttons.push({ icon: mdiPlay, action: "media_play" });
|
||||
}
|
||||
break;
|
||||
case "media_pause":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.PAUSE)) {
|
||||
buttons.push({ icon: mdiPause, action: "media_pause" });
|
||||
}
|
||||
break;
|
||||
case "media_play_pause":
|
||||
if (
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.PLAY) ||
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.PAUSE)
|
||||
) {
|
||||
buttons.push({ icon: mdiPlayPause, action: "media_play_pause" });
|
||||
}
|
||||
break;
|
||||
case "media_stop":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.STOP)) {
|
||||
buttons.push({ icon: mdiStop, action: "media_stop" });
|
||||
}
|
||||
break;
|
||||
case "media_previous_track":
|
||||
if (
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.PREVIOUS_TRACK)
|
||||
) {
|
||||
buttons.push({
|
||||
icon: mdiSkipPrevious,
|
||||
action: "media_previous_track",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "media_next_track":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.NEXT_TRACK)) {
|
||||
buttons.push({ icon: mdiSkipNext, action: "media_next_track" });
|
||||
}
|
||||
break;
|
||||
case "volume_down":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
|
||||
buttons.push({ icon: mdiVolumeMinus, action: "volume_down" });
|
||||
}
|
||||
break;
|
||||
case "volume_up":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
|
||||
buttons.push({ icon: mdiVolumePlus, action: "volume_up" });
|
||||
}
|
||||
break;
|
||||
case "volume_mute":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_MUTE)) {
|
||||
buttons.push({
|
||||
icon: stateObj.attributes.is_volume_muted
|
||||
? mdiVolumeOff
|
||||
: mdiVolumeHigh,
|
||||
action: "volume_mute",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "shuffle":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.SHUFFLE_SET)) {
|
||||
buttons.push({
|
||||
icon:
|
||||
stateObj.attributes.shuffle === true
|
||||
? mdiShuffle
|
||||
: mdiShuffleDisabled,
|
||||
action: "shuffle",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "repeat":
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.REPEAT_SET)) {
|
||||
buttons.push({
|
||||
icon:
|
||||
stateObj.attributes.repeat === "all"
|
||||
? mdiRepeat
|
||||
: stateObj.attributes.repeat === "one"
|
||||
? mdiRepeatOnce
|
||||
: mdiRepeatOff,
|
||||
action: "repeat",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return buttons;
|
||||
const buttons = computeMediaPlayerPlaybackButtons(
|
||||
stateObj,
|
||||
this._config?.controls ?? getDefaultMediaPlayerControls(stateObj)
|
||||
);
|
||||
// Disabled controls are rendered greyed out, or hidden when configured to.
|
||||
return this._filterNarrow(
|
||||
this._config?.hide_disabled_controls
|
||||
? buttons.filter((button) => !button.disabled)
|
||||
: buttons
|
||||
);
|
||||
}
|
||||
|
||||
private _filterNarrow(buttons: ControlButton[]): ControlButton[] {
|
||||
@@ -345,20 +167,6 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
const action = (e.currentTarget as HTMLElement).getAttribute("key");
|
||||
if (!action) return;
|
||||
|
||||
if (action === "media_play_pause") {
|
||||
// Resolve play_pause to the appropriate service based on state
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "volume_mute") {
|
||||
this.hass!.callService("media_player", "volume_mute", {
|
||||
entity_id: this._stateObj.entity_id,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiPowerOff,
|
||||
mdiPowerOn,
|
||||
mdiPowerStandby,
|
||||
mdiRepeat,
|
||||
mdiRepeatOff,
|
||||
mdiRepeatOnce,
|
||||
mdiShuffle,
|
||||
mdiShuffleDisabled,
|
||||
mdiSkipNext,
|
||||
mdiSkipPrevious,
|
||||
mdiStop,
|
||||
mdiVolumeHigh,
|
||||
mdiVolumeMinus,
|
||||
mdiVolumeOff,
|
||||
mdiVolumePlus,
|
||||
} from "@mdi/js";
|
||||
import { stateActive } from "../../../common/entity/state_active";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type {
|
||||
ControlButton,
|
||||
MediaPlayerEntity,
|
||||
} from "../../../data/media-player";
|
||||
import { MediaPlayerEntityFeature } from "../../../data/media-player";
|
||||
import type { MediaPlayerPlaybackControl } from "./types";
|
||||
|
||||
const MEDIA_PLAYER_PLAYBACK_CONTROLS_FEATURES: Record<
|
||||
MediaPlayerPlaybackControl,
|
||||
MediaPlayerEntityFeature[]
|
||||
> = {
|
||||
turn_on: [MediaPlayerEntityFeature.TURN_ON],
|
||||
turn_off: [MediaPlayerEntityFeature.TURN_OFF],
|
||||
media_play: [MediaPlayerEntityFeature.PLAY],
|
||||
media_pause: [MediaPlayerEntityFeature.PAUSE],
|
||||
media_play_pause: [
|
||||
MediaPlayerEntityFeature.PLAY,
|
||||
MediaPlayerEntityFeature.PAUSE,
|
||||
],
|
||||
media_stop: [MediaPlayerEntityFeature.STOP],
|
||||
media_previous_track: [MediaPlayerEntityFeature.PREVIOUS_TRACK],
|
||||
media_next_track: [MediaPlayerEntityFeature.NEXT_TRACK],
|
||||
volume_down: [MediaPlayerEntityFeature.VOLUME_STEP],
|
||||
volume_up: [MediaPlayerEntityFeature.VOLUME_STEP],
|
||||
volume_mute: [MediaPlayerEntityFeature.VOLUME_MUTE],
|
||||
shuffle: [MediaPlayerEntityFeature.SHUFFLE_SET],
|
||||
repeat: [MediaPlayerEntityFeature.REPEAT_SET],
|
||||
};
|
||||
|
||||
export const supportsMediaPlayerPlaybackControl = (
|
||||
stateObj: MediaPlayerEntity,
|
||||
control: MediaPlayerPlaybackControl
|
||||
): boolean =>
|
||||
MEDIA_PLAYER_PLAYBACK_CONTROLS_FEATURES[control].some((feature) =>
|
||||
supportsFeature(stateObj, feature)
|
||||
);
|
||||
|
||||
// Default playback row. Non-assumed players use the play/pause toggle (one
|
||||
// button, resolved by state). Assumed-state players can't reliably tell play
|
||||
// from pause, so they get separate play and pause controls (each its own
|
||||
// service).
|
||||
export const MEDIA_PLAYER_DEFAULT_CONTROLS: MediaPlayerPlaybackControl[] = [
|
||||
"media_previous_track",
|
||||
"media_play_pause",
|
||||
"media_next_track",
|
||||
];
|
||||
|
||||
const MEDIA_PLAYER_ASSUMED_DEFAULT_CONTROLS: MediaPlayerPlaybackControl[] = [
|
||||
"media_previous_track",
|
||||
"media_play",
|
||||
"media_pause",
|
||||
"media_next_track",
|
||||
];
|
||||
|
||||
export const getDefaultMediaPlayerControls = (
|
||||
stateObj?: MediaPlayerEntity
|
||||
): MediaPlayerPlaybackControl[] =>
|
||||
stateObj && isAssumed(stateObj)
|
||||
? MEDIA_PLAYER_ASSUMED_DEFAULT_CONTROLS
|
||||
: MEDIA_PLAYER_DEFAULT_CONTROLS;
|
||||
|
||||
const isPlaying = (stateObj: MediaPlayerEntity): boolean =>
|
||||
stateObj.state === "playing";
|
||||
|
||||
const isAssumed = (stateObj: MediaPlayerEntity): boolean =>
|
||||
stateObj.attributes.assumed_state === true;
|
||||
|
||||
// Track controls only make sense while there is something to play.
|
||||
const hasMediaContext = (stateObj: MediaPlayerEntity): boolean =>
|
||||
stateObj.state === "playing" ||
|
||||
stateObj.state === "paused" ||
|
||||
isAssumed(stateObj);
|
||||
|
||||
// Each builder always returns its button and flags `disabled` when the control
|
||||
// does not apply to the current state. Buttons render disabled by default; the
|
||||
// `hide_disabled_controls` option hides them instead.
|
||||
export const MEDIA_PLAYER_PLAYBACK_CONTROLS_BUTTONS: Record<
|
||||
MediaPlayerPlaybackControl,
|
||||
(stateObj: MediaPlayerEntity) => ControlButton
|
||||
> = {
|
||||
turn_on: (stateObj) => ({
|
||||
icon: isAssumed(stateObj) ? mdiPowerOn : mdiPowerStandby,
|
||||
action: "turn_on",
|
||||
// Usable while reachable and not already on.
|
||||
disabled:
|
||||
stateObj.state === UNAVAILABLE ||
|
||||
(stateActive(stateObj) && !isAssumed(stateObj)),
|
||||
}),
|
||||
turn_off: (stateObj) => ({
|
||||
icon: isAssumed(stateObj) ? mdiPowerOff : mdiPowerStandby,
|
||||
action: "turn_off",
|
||||
disabled: !stateActive(stateObj) && !isAssumed(stateObj),
|
||||
}),
|
||||
media_play: (stateObj) => ({
|
||||
icon: mdiPlay,
|
||||
action: "media_play",
|
||||
disabled:
|
||||
!isAssumed(stateObj) && (!stateActive(stateObj) || isPlaying(stateObj)),
|
||||
}),
|
||||
media_pause: (stateObj) => ({
|
||||
icon: mdiPause,
|
||||
action: "media_pause",
|
||||
disabled: !isPlaying(stateObj) && !isAssumed(stateObj),
|
||||
}),
|
||||
// Resolve to the concrete action in the builder so the icon and the called
|
||||
// service come from one decision and can't drift, and the click handler needs
|
||||
// no special case for the toggle.
|
||||
media_play_pause: (stateObj) => {
|
||||
const playing = isPlaying(stateObj);
|
||||
const canPause = supportsFeature(stateObj, MediaPlayerEntityFeature.PAUSE);
|
||||
return {
|
||||
icon: !playing ? mdiPlay : canPause ? mdiPause : mdiStop,
|
||||
action: !playing ? "media_play" : canPause ? "media_pause" : "media_stop",
|
||||
disabled: !stateActive(stateObj),
|
||||
};
|
||||
},
|
||||
media_stop: (stateObj) => ({
|
||||
icon: mdiStop,
|
||||
action: "media_stop",
|
||||
disabled: !hasMediaContext(stateObj),
|
||||
}),
|
||||
media_previous_track: (stateObj) => ({
|
||||
icon: mdiSkipPrevious,
|
||||
action: "media_previous_track",
|
||||
disabled: !hasMediaContext(stateObj),
|
||||
}),
|
||||
media_next_track: (stateObj) => ({
|
||||
icon: mdiSkipNext,
|
||||
action: "media_next_track",
|
||||
disabled: !hasMediaContext(stateObj),
|
||||
}),
|
||||
volume_down: (stateObj) => ({
|
||||
icon: mdiVolumeMinus,
|
||||
action: "volume_down",
|
||||
disabled: !stateActive(stateObj),
|
||||
}),
|
||||
volume_up: (stateObj) => ({
|
||||
icon: mdiVolumePlus,
|
||||
action: "volume_up",
|
||||
disabled: !stateActive(stateObj),
|
||||
}),
|
||||
volume_mute: (stateObj) => ({
|
||||
icon: stateObj.attributes.is_volume_muted ? mdiVolumeOff : mdiVolumeHigh,
|
||||
action: "volume_mute",
|
||||
disabled: !stateActive(stateObj),
|
||||
}),
|
||||
shuffle: (stateObj) => ({
|
||||
icon:
|
||||
stateObj.attributes.shuffle === true ? mdiShuffle : mdiShuffleDisabled,
|
||||
action: "shuffle",
|
||||
disabled: !hasMediaContext(stateObj),
|
||||
}),
|
||||
repeat: (stateObj) => ({
|
||||
icon:
|
||||
stateObj.attributes.repeat === "all"
|
||||
? mdiRepeat
|
||||
: stateObj.attributes.repeat === "one"
|
||||
? mdiRepeatOnce
|
||||
: mdiRepeatOff,
|
||||
action: "repeat",
|
||||
disabled: !hasMediaContext(stateObj),
|
||||
}),
|
||||
};
|
||||
|
||||
// Buttons for the given controls and state. Each is flagged `disabled` when not
|
||||
// usable; the caller decides whether to render them disabled or hide them.
|
||||
export const computeMediaPlayerPlaybackButtons = (
|
||||
stateObj: MediaPlayerEntity,
|
||||
controls: readonly MediaPlayerPlaybackControl[]
|
||||
): ControlButton[] =>
|
||||
controls
|
||||
.filter((control) => supportsMediaPlayerPlaybackControl(stateObj, control))
|
||||
.map((control) =>
|
||||
MEDIA_PLAYER_PLAYBACK_CONTROLS_BUTTONS[control](stateObj)
|
||||
);
|
||||
@@ -77,6 +77,7 @@ export type MediaPlayerPlaybackControl =
|
||||
export interface MediaPlayerPlaybackCardFeatureConfig {
|
||||
type: "media-player-playback";
|
||||
controls?: MediaPlayerPlaybackControl[];
|
||||
hide_disabled_controls?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaPlayerSourceCardFeatureConfig {
|
||||
|
||||
+59
-37
@@ -2,18 +2,25 @@ import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { MediaPlayerEntity } from "../../../../data/media-player";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { supportsMediaPlayerPlaybackControl } from "../../card-features/hui-media-player-playback-card-feature";
|
||||
import {
|
||||
MEDIA_PLAYER_PLAYBACK_CONTROLS,
|
||||
type LovelaceCardFeatureContext,
|
||||
type MediaPlayerPlaybackCardFeatureConfig,
|
||||
getDefaultMediaPlayerControls,
|
||||
supportsMediaPlayerPlaybackControl,
|
||||
} from "../../card-features/media-player-playback-controls";
|
||||
import type {
|
||||
LovelaceCardFeatureContext,
|
||||
MediaPlayerPlaybackCardFeatureConfig,
|
||||
} from "../../card-features/types";
|
||||
import { MEDIA_PLAYER_PLAYBACK_CONTROLS } from "../../card-features/types";
|
||||
import type { LovelaceCardFeatureEditor } from "../../types";
|
||||
import {
|
||||
customizableListData,
|
||||
customizableListSchema,
|
||||
processCustomizableListValue,
|
||||
} from "./customizable-list-feature";
|
||||
|
||||
@customElement("hui-media-player-playback-card-feature-editor")
|
||||
export class HuiMediaPlayerPlaybackCardFeatureEditor
|
||||
@@ -31,25 +38,22 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc, stateObj?: MediaPlayerEntity) =>
|
||||
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
|
||||
[
|
||||
...customizableListSchema({
|
||||
field: "controls",
|
||||
customize,
|
||||
options: MEDIA_PLAYER_PLAYBACK_CONTROLS.filter(
|
||||
(control) =>
|
||||
stateObj && supportsMediaPlayerPlaybackControl(stateObj, control)
|
||||
).map((control) => ({
|
||||
value: control,
|
||||
label: this.hass!.localize(`ui.card.media_player.${control}`),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "controls",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
mode: "list" as const,
|
||||
reorder: true,
|
||||
options: MEDIA_PLAYER_PLAYBACK_CONTROLS.filter(
|
||||
(control) =>
|
||||
stateObj &&
|
||||
supportsMediaPlayerPlaybackControl(stateObj, control)
|
||||
).map((control) => ({
|
||||
value: control,
|
||||
label: localize(`ui.card.media_player.${control}`),
|
||||
})),
|
||||
},
|
||||
},
|
||||
name: "hide_disabled_controls",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const
|
||||
);
|
||||
@@ -65,37 +69,55 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
|
||||
| undefined)
|
||||
: undefined;
|
||||
|
||||
const schema = this._schema(this.hass.localize, stateObj);
|
||||
const data = customizableListData(this._config, "controls");
|
||||
const schema = this._schema(stateObj, data.customize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
const stateObj = this.context?.entity_id
|
||||
? (this.hass!.states[this.context.entity_id] as
|
||||
| MediaPlayerEntity
|
||||
| undefined)
|
||||
: undefined;
|
||||
const defaults = getDefaultMediaPlayerControls(stateObj).filter(
|
||||
(control) =>
|
||||
stateObj && supportsMediaPlayerPlaybackControl(stateObj, control)
|
||||
);
|
||||
const config =
|
||||
processCustomizableListValue<MediaPlayerPlaybackCardFeatureConfig>(
|
||||
ev.detail.value,
|
||||
"controls",
|
||||
defaults
|
||||
);
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "controls":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.media-player-playback.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
) =>
|
||||
this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.media-player-playback.${schema.name}`
|
||||
);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
schema.name === "hide_disabled_controls"
|
||||
? this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.features.types.media-player-playback.hide_disabled_controls_helper"
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -10136,7 +10136,10 @@
|
||||
},
|
||||
"media-player-playback": {
|
||||
"label": "Media player playback controls",
|
||||
"controls": "Controls"
|
||||
"customize": "Customize controls",
|
||||
"controls": "Controls",
|
||||
"hide_disabled_controls": "Hide disabled controls",
|
||||
"hide_disabled_controls_helper": "Hide controls that don't apply to the current state instead of showing them disabled"
|
||||
},
|
||||
"media-player-sound-mode": {
|
||||
"label": "Media player sound mode",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
mdiRepeatOnce,
|
||||
mdiShuffle,
|
||||
mdiShuffleDisabled,
|
||||
mdiStop,
|
||||
mdiVolumeHigh,
|
||||
mdiVolumeOff,
|
||||
} from "@mdi/js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeMediaPlayerPlaybackButtons,
|
||||
getDefaultMediaPlayerControls,
|
||||
MEDIA_PLAYER_DEFAULT_CONTROLS,
|
||||
} from "../../../../src/panels/lovelace/card-features/media-player-playback-controls";
|
||||
import type { MediaPlayerEntity } from "../../../../src/data/media-player";
|
||||
import { MediaPlayerEntityFeature } from "../../../../src/data/media-player";
|
||||
|
||||
const player = (
|
||||
state: string,
|
||||
attributes: Partial<MediaPlayerEntity["attributes"]> = {}
|
||||
): MediaPlayerEntity =>
|
||||
({
|
||||
entity_id: "media_player.test",
|
||||
state,
|
||||
attributes,
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
}) as MediaPlayerEntity;
|
||||
|
||||
const features = (...flags: number[]): number =>
|
||||
// eslint-disable-next-line no-bitwise
|
||||
flags.reduce((acc, flag) => acc | flag, 0);
|
||||
|
||||
const ALL_FEATURES = features(
|
||||
MediaPlayerEntityFeature.TURN_ON,
|
||||
MediaPlayerEntityFeature.TURN_OFF,
|
||||
MediaPlayerEntityFeature.PLAY,
|
||||
MediaPlayerEntityFeature.PAUSE,
|
||||
MediaPlayerEntityFeature.STOP,
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK,
|
||||
MediaPlayerEntityFeature.NEXT_TRACK,
|
||||
MediaPlayerEntityFeature.VOLUME_STEP,
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE,
|
||||
MediaPlayerEntityFeature.SHUFFLE_SET,
|
||||
MediaPlayerEntityFeature.REPEAT_SET
|
||||
);
|
||||
|
||||
type Control = Parameters<typeof computeMediaPlayerPlaybackButtons>[1][number];
|
||||
|
||||
// What the card shows: buttons for a player in `state` (all features unless
|
||||
// overridden) configured with `controls`.
|
||||
const controlsFor = (
|
||||
state: string,
|
||||
controls: readonly Control[],
|
||||
attributes: Partial<MediaPlayerEntity["attributes"]> = {}
|
||||
) =>
|
||||
computeMediaPlayerPlaybackButtons(
|
||||
player(state, { supported_features: ALL_FEATURES, ...attributes }),
|
||||
controls
|
||||
);
|
||||
|
||||
const control = (
|
||||
state: string,
|
||||
action: Control,
|
||||
attributes: Partial<MediaPlayerEntity["attributes"]> = {}
|
||||
) => controlsFor(state, [action], attributes)[0];
|
||||
|
||||
const isEnabled = (
|
||||
state: string,
|
||||
action: Control,
|
||||
attributes?: Partial<MediaPlayerEntity["attributes"]>
|
||||
) => !control(state, action, attributes).disabled;
|
||||
|
||||
describe("media player playback default controls", () => {
|
||||
it("renders previous, play and next when idle, pause while playing", () => {
|
||||
expect(
|
||||
controlsFor("idle", MEDIA_PLAYER_DEFAULT_CONTROLS).map((b) => b.action)
|
||||
).toEqual(["media_previous_track", "media_play", "media_next_track"]);
|
||||
expect(
|
||||
controlsFor("playing", MEDIA_PLAYER_DEFAULT_CONTROLS).map((b) => b.action)
|
||||
).toEqual(["media_previous_track", "media_pause", "media_next_track"]);
|
||||
});
|
||||
|
||||
it("are all usable while playing", () => {
|
||||
expect(
|
||||
controlsFor("playing", MEDIA_PLAYER_DEFAULT_CONTROLS).every(
|
||||
(b) => !b.disabled
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("greys out previous/next while idle but keeps play/pause", () => {
|
||||
expect(isEnabled("idle", "media_previous_track")).toBe(false);
|
||||
expect(isEnabled("idle", "media_next_track")).toBe(false);
|
||||
expect(isEnabled("idle", "media_play_pause")).toBe(true);
|
||||
});
|
||||
|
||||
it("greys out every control when off or unavailable", () => {
|
||||
for (const state of ["off", "unavailable"]) {
|
||||
expect(
|
||||
controlsFor(state, MEDIA_PLAYER_DEFAULT_CONTROLS).every(
|
||||
(b) => b.disabled
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses separate play and pause for assumed-state players", () => {
|
||||
expect(getDefaultMediaPlayerControls(player("playing"))).toContain(
|
||||
"media_play_pause"
|
||||
);
|
||||
const assumed = getDefaultMediaPlayerControls(
|
||||
player("playing", { assumed_state: true })
|
||||
);
|
||||
expect(assumed).toContain("media_play");
|
||||
expect(assumed).toContain("media_pause");
|
||||
expect(assumed).not.toContain("media_play_pause");
|
||||
});
|
||||
});
|
||||
|
||||
describe("media player play/pause toggle", () => {
|
||||
it("shows the play icon when not playing", () => {
|
||||
expect(control("idle", "media_play_pause").icon).toBe(mdiPlay);
|
||||
expect(control("paused", "media_play_pause").icon).toBe(mdiPlay);
|
||||
});
|
||||
|
||||
it("shows the pause icon while playing", () => {
|
||||
expect(control("playing", "media_play_pause").icon).toBe(mdiPause);
|
||||
});
|
||||
|
||||
it("shows the stop icon while playing when pause is unsupported", () => {
|
||||
expect(
|
||||
control("playing", "media_play_pause", {
|
||||
supported_features: MediaPlayerEntityFeature.PLAY,
|
||||
}).icon
|
||||
).toBe(mdiStop);
|
||||
});
|
||||
|
||||
it("is available (as pause) when only pause is supported", () => {
|
||||
const buttons = controlsFor("playing", ["media_play_pause"], {
|
||||
supported_features: MediaPlayerEntityFeature.PAUSE,
|
||||
});
|
||||
expect(buttons.map((b) => b.action)).toEqual(["media_pause"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media player control availability by state", () => {
|
||||
it("enables previous/next only while playing or paused", () => {
|
||||
expect(isEnabled("idle", "media_previous_track")).toBe(false);
|
||||
expect(isEnabled("playing", "media_previous_track")).toBe(true);
|
||||
expect(isEnabled("paused", "media_next_track")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables play only when not playing", () => {
|
||||
expect(isEnabled("idle", "media_play")).toBe(true);
|
||||
expect(isEnabled("playing", "media_play")).toBe(false);
|
||||
});
|
||||
|
||||
it("enables pause and stop only while there is playback", () => {
|
||||
expect(isEnabled("idle", "media_pause")).toBe(false);
|
||||
expect(isEnabled("playing", "media_pause")).toBe(true);
|
||||
expect(isEnabled("idle", "media_stop")).toBe(false);
|
||||
expect(isEnabled("playing", "media_stop")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables power on when off and power off when active", () => {
|
||||
expect(isEnabled("off", "turn_on")).toBe(true);
|
||||
expect(isEnabled("playing", "turn_on")).toBe(false);
|
||||
expect(isEnabled("off", "turn_off")).toBe(false);
|
||||
expect(isEnabled("playing", "turn_off")).toBe(true);
|
||||
});
|
||||
|
||||
it("greys out volume controls when off", () => {
|
||||
expect(isEnabled("off", "volume_up")).toBe(false);
|
||||
expect(isEnabled("playing", "volume_up")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps play, pause and stop usable for assumed-state players", () => {
|
||||
const assumed = { assumed_state: true };
|
||||
expect(isEnabled("off", "media_play", assumed)).toBe(true);
|
||||
expect(isEnabled("off", "media_pause", assumed)).toBe(true);
|
||||
expect(isEnabled("off", "media_stop", assumed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media player control icons reflect state", () => {
|
||||
it("shows the muted/unmuted volume icon", () => {
|
||||
expect(
|
||||
control("playing", "volume_mute", { is_volume_muted: true }).icon
|
||||
).toBe(mdiVolumeOff);
|
||||
expect(
|
||||
control("playing", "volume_mute", { is_volume_muted: false }).icon
|
||||
).toBe(mdiVolumeHigh);
|
||||
});
|
||||
|
||||
it("shows the shuffle on/off icon", () => {
|
||||
expect(control("playing", "shuffle", { shuffle: true }).icon).toBe(
|
||||
mdiShuffle
|
||||
);
|
||||
expect(control("playing", "shuffle", { shuffle: false }).icon).toBe(
|
||||
mdiShuffleDisabled
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the current repeat mode icon", () => {
|
||||
expect(control("playing", "repeat", { repeat: "one" }).icon).toBe(
|
||||
mdiRepeatOnce
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media player unsupported controls", () => {
|
||||
it("drops controls the player does not support", () => {
|
||||
const supported = features(
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK,
|
||||
MediaPlayerEntityFeature.PLAY
|
||||
);
|
||||
const actions = controlsFor(
|
||||
"playing",
|
||||
["media_previous_track", "media_play_pause", "media_next_track"],
|
||||
{ supported_features: supported }
|
||||
).map((b) => b.action);
|
||||
expect(actions).toContain("media_previous_track");
|
||||
expect(actions).not.toContain("media_next_track");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user