Compare commits

...

1 Commits

Author SHA1 Message Date
Paul Bottein c22fe80b06 Show media player playback controls as disabled instead of hiding them 2026-06-02 17:55:22 +02:00
7 changed files with 508 additions and 247 deletions
+1
View File
@@ -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 {
@@ -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 {
+4 -1
View File
@@ -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");
});
});