mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-14 21:40:27 +00:00
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:
committed by
GitHub
parent
99d9c67492
commit
7be2c59295
@@ -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,
|
||||
|
||||
37
gallery/src/pages/components/ha-marquee-text.markdown
Normal file
37
gallery/src/pages/components/ha-marquee-text.markdown
Normal 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. |
|
||||
25
gallery/src/pages/components/ha-marquee-text.ts
Normal file
25
gallery/src/pages/components/ha-marquee-text.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
178
src/components/ha-marquee-text.ts
Normal file
178
src/components/ha-marquee-text.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user