Redesign media player more-info dialog (#26904)

* Redesign media player more-info dialog

* Add missing imports

* Add some more media player controls to gallery

* Fix NaN

* Fix first example source

* Regroup

* Remove

* Add marquee text

* Buttons

* aria-label

* Increase speed

* Improve marquee text

* Improve marquee text

* Improve marquee text

* Add touch events to marquee text

* Use classMap

* Remove chip styling

* Make ha-marquee-text slotted and add to gallery

* Format

* Remove aria-label

* Make turn on and off buttons have labels

* Match more figma

* Add integration logo and move grouping/inputs to top

* Hm

* Fix badge

* Minor tweaks

* Disable position slider when seek is not supported

* Process code review

* remove disabled color for slider

* Process UX

* Run prettier

* Mark listener as passive

* Improve bottom controls and styling

* Remove unused function

* Some minor improvements

* Show remaining instead duration

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Jan-Philipp Benecke
2025-09-24 16:13:37 +02:00
committed by GitHub
parent 99d9c67492
commit 7be2c59295
8 changed files with 800 additions and 228 deletions

View File

@@ -17,6 +17,10 @@ export const createMediaPlayerEntities = () => [
new Date().getTime() - 23000 new Date().getTime() - 23000
).toISOString(), ).toISOString(),
volume_level: 0.5, volume_level: 0.5,
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
source: "AirPlay",
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
}), }),
getEntity("media_player", "music_playing", "playing", { getEntity("media_player", "music_playing", "playing", {
friendly_name: "Playing The Music", friendly_name: "Playing The Music",
@@ -24,8 +28,8 @@ export const createMediaPlayerEntities = () => [
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)", media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead", media_artist: "Technohead",
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + Play Media + // Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + Play Media +
// Select Source + Stop + Clear + Play + Shuffle Set + Browse Media // Select Source + Stop + Clear + Play + Shuffle Set + Browse Media + Grouping
supported_features: 195135, supported_features: 784959,
entity_picture: "/images/album_cover.jpg", entity_picture: "/images/album_cover.jpg",
media_duration: 300, media_duration: 300,
media_position: 0, media_position: 0,
@@ -34,6 +38,9 @@ export const createMediaPlayerEntities = () => [
new Date().getTime() - 23000 new Date().getTime() - 23000
).toISOString(), ).toISOString(),
volume_level: 0.5, volume_level: 0.5,
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
group_members: ["media_player.playing", "media_player.stream_playing"],
}), }),
getEntity("media_player", "stream_playing", "playing", { getEntity("media_player", "stream_playing", "playing", {
friendly_name: "Playing the Stream", friendly_name: "Playing the Stream",
@@ -149,15 +156,18 @@ export const createMediaPlayerEntities = () => [
}), }),
getEntity("media_player", "receiver_on", "on", { getEntity("media_player", "receiver_on", "on", {
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"], source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
volume_level: 0.63, volume_level: 0.63,
is_volume_muted: false, is_volume_muted: false,
source: "TV", source: "TV",
sound_mode: "Movie",
friendly_name: "Receiver (selectable sources)", friendly_name: "Receiver (selectable sources)",
// Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode // Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode
supported_features: 84364, supported_features: 84364,
}), }),
getEntity("media_player", "receiver_off", "off", { getEntity("media_player", "receiver_off", "off", {
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"], source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
friendly_name: "Receiver (selectable sources)", friendly_name: "Receiver (selectable sources)",
// Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode // Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode
supported_features: 84364, supported_features: 84364,

View File

@@ -0,0 +1,37 @@
---
title: Marquee Text
---
# Marquee Text `<ha-marquee-text>`
Marquee text component scrolls text horizontally if it overflows its container. It supports pausing on hover and customizable speed and pause duration.
## Implementation
### Example Usage
<ha-marquee-text style="width: 200px;">
This is a long text that will scroll horizontally if it overflows the container.
</ha-marquee-text>
```html
<ha-marquee-text style="width: 200px;">
This is a long text that will scroll horizontally if it overflows the
container.
</ha-marquee-text>
```
### API
**Slots**
- default slot: The text content to be displayed and scrolled.
- no default
**Properties/Attributes**
| Name | Type | Default | Description |
| -------------- | ------- | ------- | ---------------------------------------------------------------------------- |
| speed | number | `15` | The speed of the scrolling animation. Higher values result in faster scroll. |
| pause-on-hover | boolean | `true` | Whether to pause the scrolling animation when |
| pause-duration | number | `1000` | The delay in milliseconds before the scrolling animation starts/restarts. |

View File

@@ -0,0 +1,25 @@
import { css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-marquee-text";
@customElement("demo-components-ha-marquee-text")
export class DemoHaMarqueeText extends LitElement {
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
align-items: flex-start;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-marquee-text": DemoHaMarqueeText;
}
}

View File

@@ -0,0 +1,178 @@
import {
type TemplateResult,
LitElement,
html,
css,
type PropertyValues,
} from "lit";
import { customElement, eventOptions, property, query } from "lit/decorators";
@customElement("ha-marquee-text")
export class HaMarqueeText extends LitElement {
@property({ type: Number }) speed = 15; // pixels per second
@property({ type: Number, attribute: "pause-duration" }) pauseDuration = 1000; // ms delay at ends
@property({ type: Boolean, attribute: "pause-on-hover" })
pauseOnHover = false;
private _direction: "left" | "right" = "left";
private _animationFrame?: number;
@query(".marquee-container")
private _container?: HTMLDivElement;
@query(".marquee-text")
private _textSpan?: HTMLSpanElement;
private _position = 0;
private _maxOffset = 0;
private _pauseTimeout?: number;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._setupAnimation();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("text")) {
this._setupAnimation();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
}
if (this._pauseTimeout) {
clearTimeout(this._pauseTimeout);
this._pauseTimeout = undefined;
}
}
protected render(): TemplateResult {
return html`
<div
class="marquee-container"
@mouseenter=${this._handleMouseEnter}
@mouseleave=${this._handleMouseLeave}
@touchstart=${this._handleMouseEnter}
@touchend=${this._handleMouseLeave}
>
<span class="marquee-text"><slot></slot></span>
</div>
`;
}
private _setupAnimation() {
if (!this._container || !this._textSpan) {
return;
}
this._position = 0;
this._direction = "left";
this._maxOffset = Math.max(
0,
this._textSpan.offsetWidth - this._container.offsetWidth
);
this._textSpan.style.transform = `translateX(0px)`;
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
}
if (this._pauseTimeout) {
clearTimeout(this._pauseTimeout);
this._pauseTimeout = undefined;
}
this._animate();
}
private _animate = () => {
if (!this._container || !this._textSpan) {
return;
}
const dt = 1 / 60; // ~16ms per frame
const pxPerFrame = this.speed * dt;
let reachedEnd = false;
if (this._direction === "left") {
this._position -= pxPerFrame;
if (this._position <= -this._maxOffset) {
this._position = -this._maxOffset;
this._direction = "right";
reachedEnd = true;
}
} else {
this._position += pxPerFrame;
if (this._position >= 0) {
this._position = 0;
this._direction = "left";
reachedEnd = true;
}
}
this._textSpan.style.transform = `translateX(${this._position}px)`;
if (reachedEnd) {
this._pauseTimeout = window.setTimeout(() => {
this._pauseTimeout = undefined;
this._animationFrame = requestAnimationFrame(this._animate);
}, this.pauseDuration);
} else {
this._animationFrame = requestAnimationFrame(this._animate);
}
};
@eventOptions({ passive: true })
private _handleMouseEnter() {
if (this.pauseOnHover && this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
if (this.pauseOnHover && this._pauseTimeout) {
clearTimeout(this._pauseTimeout);
this._pauseTimeout = undefined;
}
}
private _handleMouseLeave() {
if (this.pauseOnHover && !this._animationFrame && !this._pauseTimeout) {
this._animate();
}
}
static styles = css`
:host {
display: block;
overflow: hidden;
width: 100%;
}
.marquee-container {
width: 100%;
white-space: nowrap;
overflow: hidden;
user-select: none;
cursor: default;
}
.marquee-text {
display: inline-block;
vertical-align: middle;
will-change: transform;
font-size: 1em;
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-marquee-text": HaMarqueeText;
}
}

View File

@@ -34,6 +34,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"valve", "valve",
"water_heater", "water_heater",
"weather", "weather",
"media_player",
]; ];
/** Domains with full height more info dialog */ /** Domains with full height more info dialog */
export const DOMAINS_FULL_HEIGHT_MORE_INFO = ["update"]; export const DOMAINS_FULL_HEIGHT_MORE_INFO = ["update"];

View File

@@ -1,6 +1,7 @@
import { import {
mdiLoginVariant, mdiLoginVariant,
mdiMusicNote, mdiMusicNote,
mdiMusicNoteEighth,
mdiPlayBoxMultiple, mdiPlayBoxMultiple,
mdiSpeakerMultiple, mdiSpeakerMultiple,
mdiVolumeHigh, mdiVolumeHigh,
@@ -8,11 +9,13 @@ import {
mdiVolumeOff, mdiVolumeOff,
mdiVolumePlus, mdiVolumePlus,
} from "@mdi/js"; } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { stopPropagation } from "../../../common/dom/stop_propagation"; import { ifDefined } from "lit/directives/if-defined";
import { classMap } from "lit/directives/class-map";
import { stateActive } from "../../../common/entity/state_active"; import { stateActive } from "../../../common/entity/state_active";
import { supportsFeature } from "../../../common/entity/supports-feature"; import { supportsFeature } from "../../../common/entity/supports-feature";
import { formatDurationDigital } from "../../../common/datetime/format_duration";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-list-item"; import "../../../components/ha-list-item";
import "../../../components/ha-select"; import "../../../components/ha-select";
@@ -27,12 +30,17 @@ import type {
MediaPlayerEntity, MediaPlayerEntity,
} from "../../../data/media-player"; } from "../../../data/media-player";
import { import {
MediaPlayerEntityFeature,
computeMediaControls, computeMediaControls,
handleMediaControlClick, handleMediaControlClick,
MediaPlayerEntityFeature,
mediaPlayerPlayMedia, mediaPlayerPlayMedia,
} from "../../../data/media-player"; } from "../../../data/media-player";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import HassMediaPlayerEntity from "../../../util/hass-media-player-model";
import "../../../components/ha-md-button-menu";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-marquee-text";
@customElement("more-info-media_player") @customElement("more-info-media_player")
class MoreInfoMediaPlayer extends LitElement { class MoreInfoMediaPlayer extends LitElement {
@@ -40,86 +48,48 @@ class MoreInfoMediaPlayer extends LitElement {
@property({ attribute: false }) public stateObj?: MediaPlayerEntity; @property({ attribute: false }) public stateObj?: MediaPlayerEntity;
protected render() { private _formateDuration(duration: number) {
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
return formatDurationDigital(this.hass.locale, {
hours,
minutes,
seconds,
})!;
}
protected _renderVolumeControl() {
if (!this.stateObj) { if (!this.stateObj) {
return nothing; return nothing;
} }
const stateObj = this.stateObj; const supportsMute = supportsFeature(
const controls = computeMediaControls(stateObj, true); this.stateObj,
const groupMembers = stateObj.attributes.group_members?.length; MediaPlayerEntityFeature.VOLUME_MUTE
);
const supportsSliding = supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.VOLUME_SET
);
return html` return html`${(supportsFeature(
<div class="controls"> this.stateObj!,
<div class="basic-controls"> MediaPlayerEntityFeature.VOLUME_SET
${!controls ) ||
? "" supportsFeature(this.stateObj!, MediaPlayerEntityFeature.VOLUME_STEP)) &&
: controls.map( stateActive(this.stateObj!)
(control) => html`
<ha-icon-button
action=${control.action}
@click=${this._handleClick}
.path=${control.icon}
.label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)}
>
</ha-icon-button>
`
)}
</div>
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)
? html`
<ha-button
@click=${this._showBrowseMedia}
appearance="plain"
size="small"
>
<ha-svg-icon
.path=${mdiPlayBoxMultiple}
slot="start"
></ha-svg-icon>
${this.hass.localize("ui.card.media_player.browse_media")}
</ha-button>
`
: ""}
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.GROUPING)
? html`
<ha-button
@click=${this._showGroupMediaPlayers}
appearance="plain"
size="small"
>
<ha-svg-icon
.path=${mdiSpeakerMultiple}
slot="start"
></ha-svg-icon>
${groupMembers && groupMembers > 1
? html`<span class="badge">
${stateObj.attributes.group_members?.length || 4}
</span>`
: nothing}
${this.hass.localize("ui.card.media_player.join")}
</ha-button>
`
: ""}
</div>
${(supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET) ||
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) &&
stateActive(stateObj)
? html` ? html`
<div class="volume"> <div class="volume">
${supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_MUTE) ${supportsMute
? html` ? html`
<ha-icon-button <ha-icon-button
.path=${stateObj.attributes.is_volume_muted .path=${this.stateObj.attributes.is_volume_muted
? mdiVolumeOff ? mdiVolumeOff
: mdiVolumeHigh} : mdiVolumeHigh}
.label=${this.hass.localize( .label=${this.hass.localize(
`ui.card.media_player.${ `ui.card.media_player.${
stateObj.attributes.is_volume_muted this.stateObj.attributes.is_volume_muted
? "media_volume_unmute" ? "media_volume_unmute"
: "media_volume_mute" : "media_volume_mute"
}` }`
@@ -129,10 +99,9 @@ class MoreInfoMediaPlayer extends LitElement {
` `
: ""} : ""}
${supportsFeature( ${supportsFeature(
stateObj, this.stateObj,
MediaPlayerEntityFeature.VOLUME_SET MediaPlayerEntityFeature.VOLUME_STEP
) || ) && !supportsSliding
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)
? html` ? html`
<ha-icon-button <ha-icon-button
action="volume_down" action="volume_down"
@@ -151,148 +120,416 @@ class MoreInfoMediaPlayer extends LitElement {
@click=${this._handleClick} @click=${this._handleClick}
></ha-icon-button> ></ha-icon-button>
` `
: ""} : nothing}
${supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET) ${supportsSliding
? html` ? html`
${!supportsMute
? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
: nothing}
<ha-slider <ha-slider
labeled labeled
id="input" id="input"
.value=${Number(stateObj.attributes.volume_level) * 100} .value=${Number(this.stateObj.attributes.volume_level) *
100}
@change=${this._selectedValueChanged} @change=${this._selectedValueChanged}
></ha-slider> ></ha-slider>
` `
: ""} : nothing}
</div> </div>
` `
: ""} : nothing}`;
${stateActive(stateObj) && }
supportsFeature(stateObj, MediaPlayerEntityFeature.SELECT_SOURCE) &&
stateObj.attributes.source_list?.length protected _renderSourceControl() {
? html` if (
<div class="source-input"> !this.stateObj ||
<ha-select !supportsFeature(this.stateObj, MediaPlayerEntityFeature.SELECT_SOURCE) ||
.label=${this.hass.localize("ui.card.media_player.source")} !this.stateObj.attributes.source_list?.length
icon ) {
.value=${stateObj.attributes.source!} return nothing;
@selected=${this._handleSourceChanged} }
fixedMenuPosition
naturalMenuWidth return html`<ha-md-button-menu positioning="popover">
@closed=${stopPropagation} <ha-button
slot="trigger"
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(`ui.card.media_player.source`)}
> >
${stateObj.attributes.source_list!.map( <ha-svg-icon .path=${mdiLoginVariant}></ha-svg-icon>
(source) => html` </ha-button>
<ha-list-item .value=${source}> ${this.stateObj.attributes.source_list!.map(
${this.hass.formatEntityAttributeValue( (source) =>
stateObj, html`<ha-md-menu-item
"source", data-source=${source}
source @click=${this._handleSourceClick}
@keydown=${this._handleSourceClick}
.selected=${source === this.stateObj?.attributes.source}
>
${source}
</ha-md-menu-item>`
)} )}
</ha-list-item> </ha-md-button-menu>`;
` }
protected _renderSoundMode() {
if (
!this.stateObj ||
!supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.SELECT_SOUND_MODE
) ||
!this.stateObj.attributes.sound_mode_list?.length
) {
return nothing;
}
return html`<ha-md-button-menu positioning="popover">
<ha-button
slot="trigger"
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(`ui.card.media_player.sound_mode`)}
>
<ha-svg-icon .path=${mdiMusicNoteEighth}></ha-svg-icon>
</ha-button>
${this.stateObj.attributes.sound_mode_list!.map(
(soundMode) =>
html`<ha-md-menu-item
data-sound-mode=${soundMode}
@click=${this._handleSoundModeClick}
@keydown=${this._handleSoundModeClick}
.selected=${soundMode === this.stateObj?.attributes.sound_mode}
>
${soundMode}
</ha-md-menu-item>`
)} )}
<ha-svg-icon .path=${mdiLoginVariant} slot="icon"></ha-svg-icon> </ha-md-button-menu>`;
</ha-select> }
protected _renderGrouping() {
if (
!this.stateObj ||
isUnavailableState(this.stateObj.state) ||
!supportsFeature(this.stateObj, MediaPlayerEntityFeature.GROUPING)
) {
return nothing;
}
const groupMembers = this.stateObj.attributes.group_members;
const hasMultipleMembers = groupMembers && groupMembers?.length > 1;
return html`<ha-button
class="grouping"
@click=${this._showGroupMediaPlayers}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize("ui.card.media_player.join")}
>
<div>
<ha-svg-icon .path=${mdiSpeakerMultiple}></ha-svg-icon>
${hasMultipleMembers
? html`<span class="badge"> ${groupMembers?.length || 4} </span>`
: nothing}
</div>
</ha-button>`;
}
protected _renderEmptyCover(title: string, icon?: string) {
return html`
<div class="cover-container">
<div class="cover-image empty-cover" role="img" aria-label=${title}>
${icon ? html`<ha-svg-icon .path=${icon}></ha-svg-icon>` : title}
</div>
</div>
`;
}
protected render() {
if (!this.stateObj) {
return nothing;
}
if (isUnavailableState(this.stateObj.state)) {
return this._renderEmptyCover(this.hass.formatEntityState(this.stateObj));
}
const stateObj = this.stateObj;
const controls = computeMediaControls(stateObj, true);
const coverUrl = stateObj.attributes.entity_picture || "";
const playerObj = new HassMediaPlayerEntity(this.hass, this.stateObj);
const position = Math.floor(playerObj.currentProgress) || 0;
const duration = stateObj.attributes.media_duration || 0;
const remaining = duration - position;
const durationFormated =
remaining > 0 ? this._formateDuration(remaining) : 0;
const postionFormated = this._formateDuration(position);
const primaryTitle = playerObj.primaryTitle;
const secondaryTitle = playerObj.secondaryTitle;
const turnOn = controls?.find((c) => c.action === "turn_on");
const turnOff = controls?.find((c) => c.action === "turn_off");
return html`
${coverUrl
? html`<div class="cover-container">
<img
class=${classMap({
"cover-image": true,
"cover-image--playing": stateObj.state === "playing",
})}
src=${coverUrl}
alt=${ifDefined(primaryTitle)}
/>
</div>`
: this._renderEmptyCover(
this.hass.formatEntityState(this.stateObj),
mdiMusicNote
)}
${primaryTitle || secondaryTitle
? html`<div class="media-info-row">
${primaryTitle
? html`<ha-marquee-text
class="media-title"
speed="30"
pause-on-hover
>
${primaryTitle}
</ha-marquee-text>`
: nothing}
${secondaryTitle
? html`<ha-marquee-text
class="media-artist"
speed="30"
pause-on-hover
>
${secondaryTitle}
</ha-marquee-text>`
: nothing}
</div>`
: nothing}
${duration && duration > 0
? html`
<div class="position-bar">
<ha-slider
min="0"
max=${duration}
step="1"
.value=${position}
aria-label=${this.hass.localize(
"ui.card.media_player.track_position"
)}
@change=${this._handleMediaSeekChanged}
?disabled=${!stateActive(stateObj) ||
!supportsFeature(stateObj, MediaPlayerEntityFeature.SEEK)}
></ha-slider>
<div class="position-info-row">
<span class="position-time">${postionFormated}</span>
<span class="duration-time">${durationFormated}</span>
</div>
</div> </div>
` `
: nothing} : nothing}
${stateActive(stateObj) && <div class="bottom-controls">
supportsFeature(stateObj, MediaPlayerEntityFeature.SELECT_SOUND_MODE) && ${controls && controls.length > 0
stateObj.attributes.sound_mode_list?.length ? html`<div class="main-controls">
? html` ${["repeat_set", "media_previous_track"].map((action) => {
<div class="sound-input"> const control = controls?.find((c) => c.action === action);
<ha-select return control
.label=${this.hass.localize("ui.card.media_player.sound_mode")} ? html`<ha-icon-button
.value=${stateObj.attributes.sound_mode!} action=${action}
icon @click=${this._handleClick}
fixedMenuPosition .path=${control.icon}
naturalMenuWidth .label=${this.hass.localize(
@selected=${this._handleSoundModeChanged} `ui.card.media_player.${control.action}`
@closed=${stopPropagation} )}
> >
${stateObj.attributes.sound_mode_list.map( </ha-icon-button>`
(mode) => html` : html`<span class="spacer"></span>`;
<ha-list-item .value=${mode}> })}
${this.hass.formatEntityAttributeValue( ${["media_play_pause", "media_pause", "media_play"].map(
stateObj, (action) => {
"sound_mode", const control = controls?.find((c) => c.action === action);
mode return control
? html`<ha-button
variant="brand"
appearance="filled"
size="medium"
action=${action}
@click=${this._handleClick}
class="center-control"
>
<ha-svg-icon
.path=${control.icon}
aria-label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)} )}
</ha-list-item> ></ha-svg-icon>
</ha-button>`
: nothing;
}
)}
${["media_next_track", "shuffle_set"].map((action) => {
const control = controls?.find((c) => c.action === action);
return control
? html`<ha-icon-button
action=${action}
@click=${this._handleClick}
.path=${control.icon}
.label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)}
>
</ha-icon-button>`
: html`<span class="spacer"></span>`;
})}
</div>`
: nothing}
${this._renderVolumeControl()}
<div class="controls-row">
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)
? html`
<ha-button
@click=${this._showBrowseMedia}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(
"ui.card.media_player.browse_media"
)}
>
<ha-svg-icon .path=${mdiPlayBoxMultiple}></ha-svg-icon>
</ha-button>
` `
: nothing}
${this._renderGrouping()} ${this._renderSourceControl()}
${this._renderSoundMode()}
${turnOn
? html`<ha-button
action=${turnOn.action}
@click=${this._handleClick}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(
`ui.card.media_player.${turnOn.action}`
)} )}
<ha-svg-icon .path=${mdiMusicNote} slot="icon"></ha-svg-icon> >
</ha-select> <ha-svg-icon .path=${turnOn.icon}></ha-svg-icon>
</ha-button>`
: nothing}
${turnOff
? html`<ha-button
action=${turnOff.action}
@click=${this._handleClick}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(
`ui.card.media_player.${turnOff.action}`
)}
>
<ha-svg-icon .path=${turnOff.icon}></ha-svg-icon>
</ha-button>`
: nothing}
</div>
</div> </div>
`
: ""}
`; `;
} }
static styles = css` static styles = css`
ha-slider { :host {
flex-grow: 1;
}
ha-icon-button[action="turn_off"],
ha-icon-button[action="turn_on"] {
margin-right: auto;
margin-left: inherit;
margin-inline-start: inherit;
margin-inline-end: auto;
}
.controls {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 24px;
margin-top: 0;
}
.cover-container {
display: flex;
justify-content: center;
align-items: center; align-items: center;
--mdc-theme-primary: currentColor; height: 320px;
direction: ltr; width: 100%;
} }
.basic-controls { .cover-image {
display: inline-flex; width: 240px;
flex-grow: 1; height: 240px;
max-width: 100%;
max-height: 100%;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
display: flex;
align-items: center;
justify-content: center;
transition:
width 0.3s,
height 0.3s;
} }
.volume { .cover-image--playing {
direction: ltr; width: 320px;
height: 320px;
} }
.source-input, .empty-cover {
.sound-input { background-color: var(--secondary-background-color);
direction: var(--direction); font-size: 1.5em;
color: var(--secondary-text-color);
} }
.volume, .main-controls {
.source-input,
.sound-input {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.source-input ha-select, .center-control {
.sound-input ha-select { --ha-button-height: 56px;
margin-left: 10px;
flex-grow: 1;
margin-inline-start: 10px;
margin-inline-end: initial;
direction: var(--direction);
} }
.tts { .spacer {
margin-top: 16px; width: 48px;
font-style: italic;
} }
ha-button > ha-svg-icon { .volume,
vertical-align: text-bottom; .position-bar,
.main-controls {
direction: ltr;
}
.volume ha-slider,
.position-bar ha-slider {
width: 100%;
}
.volume {
display: flex;
align-items: center;
gap: 12px;
margin-left: 8px;
}
.volume ha-svg-icon {
padding: 4px;
height: 16px;
width: 16px;
}
.volume ha-icon-button {
--mdc-icon-button-size: 32px;
--mdc-icon-size: 16px;
} }
.badge { .badge {
position: absolute; position: absolute;
top: -6px; top: -10px;
left: 24px; left: 16px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -301,9 +538,68 @@ class MoreInfoMediaPlayer extends LitElement {
border-radius: 10px; border-radius: 10px;
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
font-size: var(--ha-font-size-xs); font-size: var(--ha-font-size-xs);
background-color: var(--accent-color); background-color: var(--primary-color);
padding: 0 4px; padding: 0 4px;
color: var(--text-accent-color, var(--text-primary-color)); color: var(--primary-text-color);
}
.position-bar {
display: flex;
flex-direction: column;
}
.position-info-row {
display: flex;
flex-direction: row;
justify-content: space-between;
color: var(--secondary-text-color);
padding: 0 8px;
font-size: var(--ha-font-size-s);
}
.media-info-row {
display: flex;
flex-direction: column;
justify-content: space-between;
margin: 8px 0 8px 8px;
}
.media-title {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold);
margin-bottom: 4px;
}
.media-artist {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-normal);
color: var(--secondary-text-color);
}
.controls-row {
display: flex;
align-items: center;
justify-content: space-around;
}
.controls-row ha-button {
width: 32px;
}
.controls-row ha-svg-icon {
color: var(--ha-color-on-neutral-quiet);
}
.grouping::part(label) {
position: relative;
}
.bottom-controls {
display: flex;
flex-direction: column;
gap: 24px;
align-self: center;
width: 320px;
} }
`; `;
@@ -329,29 +625,29 @@ class MoreInfoMediaPlayer extends LitElement {
}); });
} }
private _handleSourceChanged(e) { private _handleSourceClick(e: Event) {
const newVal = e.target.value; const source = (e.currentTarget as HTMLElement).getAttribute("data-source");
if (!source || this.stateObj!.attributes.source === source) {
if (!newVal || this.stateObj!.attributes.source === newVal) {
return; return;
} }
this.hass.callService("media_player", "select_source", { this.hass.callService("media_player", "select_source", {
entity_id: this.stateObj!.entity_id, entity_id: this.stateObj!.entity_id,
source: newVal, source,
}); });
} }
private _handleSoundModeChanged(e) { private _handleSoundModeClick(e: Event) {
const newVal = e.target.value; const soundMode = (e.currentTarget as HTMLElement).getAttribute(
"data-sound-mode"
if (!newVal || this.stateObj?.attributes.sound_mode === newVal) { );
if (!soundMode || this.stateObj!.attributes.sound_mode === soundMode) {
return; return;
} }
this.hass.callService("media_player", "select_sound_mode", { this.hass.callService("media_player", "select_sound_mode", {
entity_id: this.stateObj!.entity_id, entity_id: this.stateObj!.entity_id,
sound_mode: newVal, sound_mode: soundMode,
}); });
} }
@@ -374,6 +670,18 @@ class MoreInfoMediaPlayer extends LitElement {
entityId: this.stateObj!.entity_id, entityId: this.stateObj!.entity_id,
}); });
} }
private async _handleMediaSeekChanged(e: Event): Promise<void> {
if (!this.stateObj) {
return;
}
const newValue = (e.target as any).value;
this.hass.callService("media_player", "media_seek", {
entity_id: this.stateObj.entity_id,
seek_position: newValue,
});
}
} }
declare global { declare global {

View File

@@ -22,6 +22,7 @@ import { demoPanels } from "./demo_panels";
import { demoServices } from "./demo_services"; import { demoServices } from "./demo_services";
import type { Entity } from "./entity"; import type { Entity } from "./entity";
import { getEntity } from "./entity"; import { getEntity } from "./entity";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
const ensureArray = <T>(val: T | T[]): T[] => const ensureArray = <T>(val: T | T[]): T[] =>
Array.isArray(val) ? val : [val]; Array.isArray(val) ? val : [val];
@@ -147,6 +148,17 @@ export const provideHass = (
} else { } else {
updateStates(states); updateStates(states);
} }
for (const ent of ensureArray(newEntities)) {
hass().entities[ent.entityId] = {
entity_id: ent.entityId,
name: ent.name,
icon: ent.icon,
platform: "demo",
labels: [],
} satisfies EntityRegistryDisplayEntry;
}
updateFormatFunctions(); updateFormatFunctions();
} }

View File

@@ -233,7 +233,8 @@
"join": "Join", "join": "Join",
"media_players": "Media players", "media_players": "Media players",
"select_all": "Select all", "select_all": "Select all",
"idle": "Idle" "idle": "Idle",
"track_position": "Track position"
}, },
"persistent_notification": { "persistent_notification": {
"dismiss": "Dismiss" "dismiss": "Dismiss"