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

View File

@@ -1,6 +1,7 @@
import {
mdiLoginVariant,
mdiMusicNote,
mdiMusicNoteEighth,
mdiPlayBoxMultiple,
mdiSpeakerMultiple,
mdiVolumeHigh,
@@ -8,11 +9,13 @@ import {
mdiVolumeOff,
mdiVolumePlus,
} from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
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 { supportsFeature } from "../../../common/entity/supports-feature";
import { formatDurationDigital } from "../../../common/datetime/format_duration";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-select";
@@ -27,12 +30,17 @@ import type {
MediaPlayerEntity,
} from "../../../data/media-player";
import {
MediaPlayerEntityFeature,
computeMediaControls,
handleMediaControlClick,
MediaPlayerEntityFeature,
mediaPlayerPlayMedia,
} from "../../../data/media-player";
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")
class MoreInfoMediaPlayer extends LitElement {
@@ -40,86 +48,48 @@ class MoreInfoMediaPlayer extends LitElement {
@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) {
return nothing;
}
const stateObj = this.stateObj;
const controls = computeMediaControls(stateObj, true);
const groupMembers = stateObj.attributes.group_members?.length;
const supportsMute = supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.VOLUME_MUTE
);
const supportsSliding = supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.VOLUME_SET
);
return html`
<div class="controls">
<div class="basic-controls">
${!controls
? ""
: controls.map(
(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)
return html`${(supportsFeature(
this.stateObj!,
MediaPlayerEntityFeature.VOLUME_SET
) ||
supportsFeature(this.stateObj!, MediaPlayerEntityFeature.VOLUME_STEP)) &&
stateActive(this.stateObj!)
? html`
<div class="volume">
${supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_MUTE)
${supportsMute
? html`
<ha-icon-button
.path=${stateObj.attributes.is_volume_muted
.path=${this.stateObj.attributes.is_volume_muted
? mdiVolumeOff
: mdiVolumeHigh}
.label=${this.hass.localize(
`ui.card.media_player.${
stateObj.attributes.is_volume_muted
this.stateObj.attributes.is_volume_muted
? "media_volume_unmute"
: "media_volume_mute"
}`
@@ -129,10 +99,9 @@ class MoreInfoMediaPlayer extends LitElement {
`
: ""}
${supportsFeature(
stateObj,
MediaPlayerEntityFeature.VOLUME_SET
) ||
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)
this.stateObj,
MediaPlayerEntityFeature.VOLUME_STEP
) && !supportsSliding
? html`
<ha-icon-button
action="volume_down"
@@ -151,148 +120,416 @@ class MoreInfoMediaPlayer extends LitElement {
@click=${this._handleClick}
></ha-icon-button>
`
: ""}
${supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET)
: nothing}
${supportsSliding
? html`
${!supportsMute
? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
: nothing}
<ha-slider
labeled
id="input"
.value=${Number(stateObj.attributes.volume_level) * 100}
.value=${Number(this.stateObj.attributes.volume_level) *
100}
@change=${this._selectedValueChanged}
></ha-slider>
`
: ""}
: nothing}
</div>
`
: ""}
${stateActive(stateObj) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.SELECT_SOURCE) &&
stateObj.attributes.source_list?.length
? html`
<div class="source-input">
<ha-select
.label=${this.hass.localize("ui.card.media_player.source")}
icon
.value=${stateObj.attributes.source!}
@selected=${this._handleSourceChanged}
fixedMenuPosition
naturalMenuWidth
@closed=${stopPropagation}
: nothing}`;
}
protected _renderSourceControl() {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, MediaPlayerEntityFeature.SELECT_SOURCE) ||
!this.stateObj.attributes.source_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.source`)}
>
${stateObj.attributes.source_list!.map(
(source) => html`
<ha-list-item .value=${source}>
${this.hass.formatEntityAttributeValue(
stateObj,
"source",
source
<ha-svg-icon .path=${mdiLoginVariant}></ha-svg-icon>
</ha-button>
${this.stateObj.attributes.source_list!.map(
(source) =>
html`<ha-md-menu-item
data-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-select>
</ha-md-button-menu>`;
}
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>
`
: nothing}
${stateActive(stateObj) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.SELECT_SOUND_MODE) &&
stateObj.attributes.sound_mode_list?.length
? html`
<div class="sound-input">
<ha-select
.label=${this.hass.localize("ui.card.media_player.sound_mode")}
.value=${stateObj.attributes.sound_mode!}
icon
fixedMenuPosition
naturalMenuWidth
@selected=${this._handleSoundModeChanged}
@closed=${stopPropagation}
<div class="bottom-controls">
${controls && controls.length > 0
? html`<div class="main-controls">
${["repeat_set", "media_previous_track"].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}`
)}
>
${stateObj.attributes.sound_mode_list.map(
(mode) => html`
<ha-list-item .value=${mode}>
${this.hass.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
</ha-icon-button>`
: html`<span class="spacer"></span>`;
})}
${["media_play_pause", "media_pause", "media_play"].map(
(action) => {
const control = controls?.find((c) => c.action === action);
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>
`
: ""}
`;
}
static styles = css`
ha-slider {
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 {
:host {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 24px;
margin-top: 0;
}
.cover-container {
display: flex;
justify-content: center;
align-items: center;
--mdc-theme-primary: currentColor;
direction: ltr;
height: 320px;
width: 100%;
}
.basic-controls {
display: inline-flex;
flex-grow: 1;
.cover-image {
width: 240px;
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 {
direction: ltr;
.cover-image--playing {
width: 320px;
height: 320px;
}
.source-input,
.sound-input {
direction: var(--direction);
.empty-cover {
background-color: var(--secondary-background-color);
font-size: 1.5em;
color: var(--secondary-text-color);
}
.volume,
.source-input,
.sound-input {
.main-controls {
display: flex;
align-items: center;
justify-content: space-between;
}
.source-input ha-select,
.sound-input ha-select {
margin-left: 10px;
flex-grow: 1;
margin-inline-start: 10px;
margin-inline-end: initial;
direction: var(--direction);
.center-control {
--ha-button-height: 56px;
}
.tts {
margin-top: 16px;
font-style: italic;
.spacer {
width: 48px;
}
ha-button > ha-svg-icon {
vertical-align: text-bottom;
.volume,
.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 {
position: absolute;
top: -6px;
left: 24px;
top: -10px;
left: 16px;
display: flex;
justify-content: center;
align-items: center;
@@ -301,9 +538,68 @@ class MoreInfoMediaPlayer extends LitElement {
border-radius: 10px;
font-weight: var(--ha-font-weight-normal);
font-size: var(--ha-font-size-xs);
background-color: var(--accent-color);
background-color: var(--primary-color);
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) {
const newVal = e.target.value;
if (!newVal || this.stateObj!.attributes.source === newVal) {
private _handleSourceClick(e: Event) {
const source = (e.currentTarget as HTMLElement).getAttribute("data-source");
if (!source || this.stateObj!.attributes.source === source) {
return;
}
this.hass.callService("media_player", "select_source", {
entity_id: this.stateObj!.entity_id,
source: newVal,
source,
});
}
private _handleSoundModeChanged(e) {
const newVal = e.target.value;
if (!newVal || this.stateObj?.attributes.sound_mode === newVal) {
private _handleSoundModeClick(e: Event) {
const soundMode = (e.currentTarget as HTMLElement).getAttribute(
"data-sound-mode"
);
if (!soundMode || this.stateObj!.attributes.sound_mode === soundMode) {
return;
}
this.hass.callService("media_player", "select_sound_mode", {
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,
});
}
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 {

View File

@@ -22,6 +22,7 @@ import { demoPanels } from "./demo_panels";
import { demoServices } from "./demo_services";
import type { Entity } from "./entity";
import { getEntity } from "./entity";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
const ensureArray = <T>(val: T | T[]): T[] =>
Array.isArray(val) ? val : [val];
@@ -147,6 +148,17 @@ export const provideHass = (
} else {
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();
}

View File

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