From 9b220cc6ce2ff218b23682f34988a213ce53ae11 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 12 Mar 2020 02:40:03 -0700 Subject: [PATCH] Extract media controls into method (#5141) * Extract media controls into method * address comments * lint * Moooorre fixes * Fix margin * Update demos * Very narrow idle players show play button * Lint * More stuff * Marquee on steroids --- gallery/src/data/media_players.ts | 57 ++-- .../src/demos/demo-hui-media-control-card.ts | 16 +- .../src/demos/demo-hui-media-player-rows.ts | 12 +- src/common/entity/domain_icon.ts | 4 +- src/data/media-player.ts | 5 +- .../lovelace/cards/hui-media-control-card.ts | 301 ++++++++++-------- src/panels/lovelace/components/hui-marquee.ts | 43 ++- .../hui-media-player-entity-row.ts | 3 +- src/types.ts | 1 + 9 files changed, 250 insertions(+), 192 deletions(-) diff --git a/gallery/src/data/media_players.ts b/gallery/src/data/media_players.ts index 93102f9fb6..7eb39a8bd5 100644 --- a/gallery/src/data/media_players.ts +++ b/gallery/src/data/media_players.ts @@ -1,23 +1,36 @@ import { getEntity } from "../../../src/fake_data/entity"; export const createMediaPlayerEntities = () => [ - getEntity("media_player", "bedroom", "playing", { - media_content_type: "movie", - media_title: "Epic sax guy 10 hours", - app_name: "YouTube", - friendly_name: "Skip, no pause", - supported_features: 32, - }), - getEntity("media_player", "family_room", "paused", { - friendly_name: "Paused, music", + getEntity("media_player", "music_paused", "paused", { + friendly_name: "Pausing The Music", media_content_type: "music", media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)", media_artist: "Technohead", - supported_features: 16417, + supported_features: 64063, entity_picture: "/images/album_cover.jpg", + media_duration: 300, + media_position: 50, + media_position_updated_at: new Date( + // 23 seconds in + new Date().getTime() - 23000 + ).toISOString(), }), - getEntity("media_player", "family_room_no_play", "paused", { - friendly_name: "Paused, no play", + getEntity("media_player", "music_playing", "playing", { + friendly_name: "Playing The Music", + media_content_type: "music", + media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)", + media_artist: "Technohead", + supported_features: 64063, + entity_picture: "/images/album_cover.jpg", + media_duration: 300, + media_position: 0, + media_position_updated_at: new Date( + // 23 seconds in + new Date().getTime() - 23000 + ).toISOString(), + }), + getEntity("media_player", "stream_playing", "playing", { + friendly_name: "Playing the Stream", media_content_type: "movie", media_title: "Epic sax guy 10 hours", app_name: "YouTube", @@ -31,25 +44,19 @@ export const createMediaPlayerEntities = () => [ app_name: "Netflix", supported_features: 1, }), - getEntity("media_player", "lounge_room", "idle", { - friendly_name: "Screen casting", - media_content_type: "music", - media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)", - media_artist: "Technohead", - supported_features: 1, + getEntity("media_player", "sonos_idle", "idle", { + friendly_name: "Sonos Idle", + supported_features: 64063, }), getEntity("media_player", "theater", "off", { - friendly_name: "Chromcast Idle", - media_content_type: "movie", - media_title: "Epic sax guy 10 hours", - app_name: "YouTube", - supported_features: 33, + friendly_name: "TV Off", + supported_features: 161, }), getEntity("media_player", "android_cast", "playing", { - friendly_name: "Player Off", + friendly_name: "Casting App", media_title: "Android Screen Casting", app_name: "Screen Mirroring", - supported_features: 21437, + // supported_features: 21437, }), getEntity("media_player", "unavailable", "unavailable", { friendly_name: "Player Unavailable", diff --git a/gallery/src/demos/demo-hui-media-control-card.ts b/gallery/src/demos/demo-hui-media-control-card.ts index e9b0599ac6..08e2b5bfb0 100644 --- a/gallery/src/demos/demo-hui-media-control-card.ts +++ b/gallery/src/demos/demo-hui-media-control-card.ts @@ -7,24 +7,24 @@ import { createMediaPlayerEntities } from "../data/media_players"; const CONFIGS = [ { - heading: "Skip, no pause", + heading: "Paused music", config: ` - type: media-control - entity: media_player.bedroom + entity: media_player.music_paused `, }, { - heading: "Paused, music", + heading: "Playing music", config: ` - type: media-control - entity: media_player.family_room + entity: media_player.music_playing `, }, { - heading: "Paused, no play", + heading: "Playing stream", config: ` - type: media-control - entity: media_player.family_room_no_play + entity: media_player.stream_playing `, }, { @@ -42,10 +42,10 @@ const CONFIGS = [ `, }, { - heading: "Chromcast Idle", + heading: "Sonos Idle", config: ` - type: media-control - entity: media_player.lounge_room + entity: media_player.sonos_idle `, }, { diff --git a/gallery/src/demos/demo-hui-media-player-rows.ts b/gallery/src/demos/demo-hui-media-player-rows.ts index 98a2e41823..5cf708cb9f 100644 --- a/gallery/src/demos/demo-hui-media-player-rows.ts +++ b/gallery/src/demos/demo-hui-media-player-rows.ts @@ -11,17 +11,17 @@ const CONFIGS = [ config: ` - type: entities entities: - - entity: media_player.bedroom - name: Skip, no pause - - entity: media_player.family_room - name: Paused, music - - entity: media_player.family_room_no_play + - entity: media_player.music_paused + name: Paused music + - entity: media_player.music_playing + name: Playing music + - entity: media_player.stream_playing name: Paused, no play - entity: media_player.living_room name: Pause, No skip, tvshow - entity: media_player.android_cast name: Screen casting - - entity: media_player.lounge_room + - entity: media_player.sonos_idle name: Chromcast Idle - entity: media_player.theater name: Player Off diff --git a/src/common/entity/domain_icon.ts b/src/common/entity/domain_icon.ts index aff6fc346d..baa698de4c 100644 --- a/src/common/entity/domain_icon.ts +++ b/src/common/entity/domain_icon.ts @@ -83,9 +83,7 @@ export const domainIcon = (domain: string, state?: string): string => { return state && state === "unlocked" ? "hass:lock-open" : "hass:lock"; case "media_player": - return state && state !== "off" && state !== "idle" - ? "hass:cast-connected" - : "hass:cast"; + return state && state === "playing" ? "hass:cast-connected" : "hass:cast"; case "zwave": switch (state) { diff --git a/src/data/media-player.ts b/src/data/media-player.ts index 1a3804ed56..2527731956 100644 --- a/src/data/media-player.ts +++ b/src/data/media-player.ts @@ -14,7 +14,6 @@ export const SUPPORT_SELECT_SOURCE = 2048; export const SUPPORT_STOP = 4096; export const SUPPORTS_PLAY = 16384; export const SUPPORT_SELECT_SOUND_MODE = 65536; -export const OFF_STATES = ["off", "idle"]; export const CONTRAST_RATIO = 3.5; export interface MediaPlayerThumbnail { @@ -56,9 +55,7 @@ export const computeMediaDescription = (stateObj: HassEntity): string => { } break; default: - secondaryTitle = stateObj.attributes.app_name - ? stateObj.attributes.app_name - : ""; + secondaryTitle = stateObj.attributes.app_name || ""; } return secondaryTitle; diff --git a/src/panels/lovelace/cards/hui-media-control-card.ts b/src/panels/lovelace/cards/hui-media-control-card.ts index bd2b224854..c25a356e34 100644 --- a/src/panels/lovelace/cards/hui-media-control-card.ts +++ b/src/panels/lovelace/cards/hui-media-control-card.ts @@ -33,7 +33,6 @@ import { findEntities } from "../common/find-entites"; import { LovelaceConfig } from "../../../data/lovelace"; import { UNAVAILABLE, UNKNOWN } from "../../../data/entity"; import { - OFF_STATES, SUPPORT_PAUSE, SUPPORT_TURN_ON, SUPPORT_PREVIOUS_TRACK, @@ -49,6 +48,8 @@ import { import "../../../components/ha-card"; import "../../../components/ha-icon"; import "../components/hui-marquee"; +// tslint:disable-next-line: no-duplicate-imports +import { PaperIconButtonElement } from "@polymer/paper-icon-button/paper-icon-button"; function getContrastRatio( rgb1: [number, number, number], @@ -57,6 +58,11 @@ function getContrastRatio( return Math.round((contrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100; } +interface ControlButton { + icon: string; + action: string; +} + @customElement("hui-media-control-card") export class HuiMediaControlCard extends LitElement implements LovelaceCard { public static async getConfigElement(): Promise { @@ -118,7 +124,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { return; } - const stateObj = this.hass.states[this._config.entity] as MediaEntity; + const stateObj = this._stateObj; if (!stateObj) { return; @@ -130,7 +136,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { stateObj.state === "playing" ) { this._progressInterval = window.setInterval( - () => this._updateProgressBar(stateObj), + () => this._updateProgressBar(), 1000 ); } @@ -147,7 +153,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { if (!this.hass || !this._config) { return html``; } - const stateObj = this.hass.states[this._config.entity] as MediaEntity; + const stateObj = this._stateObj; if (!stateObj) { return html` @@ -162,7 +168,9 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { } const imageStyle = { - "background-image": `url(${this.hass.hassUrl(this._image)})`, + "background-image": this._image + ? `url(${this.hass.hassUrl(this._image)})` + : "none", width: `${this._cardHeight}px`, "background-color": this._backgroundColor || "", }; @@ -172,12 +180,19 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { width: `${this._cardHeight}px`, }; - const isOffState = OFF_STATES.includes(stateObj.state); + const state = stateObj.state; + + const isOffState = state === "off"; const isUnavailable = - stateObj.state === UNAVAILABLE || - stateObj.state === UNKNOWN || - (stateObj.state === "off" && !supportsFeature(stateObj, SUPPORT_TURN_ON)); + state === UNAVAILABLE || + state === UNKNOWN || + (state === "off" && !supportsFeature(stateObj, SUPPORT_TURN_ON)); const hasNoImage = !this._image; + const controls = this._getControls(); + const showControls = + controls && (!this._veryNarrow || isOffState || state === "idle"); + + const mediaDescription = computeMediaDescription(stateObj); return html` @@ -215,7 +230,8 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { "no-image": hasNoImage, narrow: this._narrow && !this._veryNarrow, off: isOffState || isUnavailable, - "no-progress": !this._showProgressBar && !this._veryNarrow, + "no-progress": this._veryNarrow || !this._showProgressBar, + "no-controls": !showControls, })}" style=${styleMap({ color: this._foregroundColor || "" })} > @@ -246,98 +262,35 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { : `${this._cardHeight - 40}px`, })} > - ${isOffState + ${!mediaDescription && !stateObj.attributes.media_title ? "" : html`
-
- -
+ ${!stateObj.attributes.media_title ? "" - : computeMediaDescription(stateObj)} + : mediaDescription}
`} - ${this._veryNarrow && !isOffState + ${!showControls ? "" : html`
-
- ${(stateObj.state === "off" && - !supportsFeature(stateObj, SUPPORT_TURN_ON)) || - !isOffState - ? "" - : html` - - `} -
- ${isOffState - ? "" - : html` -
- ${!supportsFeature( - stateObj, - SUPPORT_PREVIOUS_TRACK - ) - ? "" - : html` - - `} - ${(stateObj.state !== "playing" && - !supportsFeature( - stateObj, - SUPPORTS_PLAY - )) || - stateObj.state === UNAVAILABLE || - (stateObj.state === "playing" && - !supportsFeature(stateObj, SUPPORT_PAUSE) && - !supportsFeature(stateObj, SUPPORT_STOP)) - ? "" - : html` - - `} - ${!supportsFeature( - stateObj, - SUPPORT_NEXT_TRACK - ) - ? "" - : html` - - `} -
- `} + ${controls!.map( + (control) => html` + + ` + )}
`} @@ -346,7 +299,6 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { : html` - this._handleSeek(e, stateObj)} + @click=${this._handleSeek} > `} `} @@ -374,13 +325,20 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { protected updated(changedProps: PropertyValues): void { super.updated(changedProps); + if (!this._config || !this.hass || !changedProps.has("hass")) { return; } - const stateObj = this.hass.states[this._config.entity] as MediaEntity; + const stateObj = this._stateObj; if (!stateObj) { + if (this._progressInterval) { + clearInterval(this._progressInterval); + this._progressInterval = undefined; + } + this._foregroundColor = undefined; + this._backgroundColor = undefined; return; } @@ -398,13 +356,15 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { applyThemesOnElement(this, this.hass.themes, this._config.theme); } + this._updateProgressBar(); + if ( !this._progressInterval && this._showProgressBar && stateObj.state === "playing" ) { this._progressInterval = window.setInterval( - () => this._updateProgressBar(stateObj), + () => this._updateProgressBar(), 1000 ); } else if ( @@ -427,16 +387,86 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { if (this._image !== oldImage) { this._setColors(); - return; } } + private _getControls(): ControlButton[] | undefined { + const stateObj = this._stateObj; + + if (!stateObj) { + return undefined; + } + + const state = stateObj.state; + + if (state === UNAVAILABLE || state === UNKNOWN) { + return undefined; + } + + if (state === "off") { + return supportsFeature(stateObj, SUPPORT_TURN_ON) + ? [ + { + icon: "hass:power", + action: "turn_on", + }, + ] + : undefined; + } + + if (state === "idle") { + return supportsFeature(stateObj, SUPPORTS_PLAY) + ? [ + { + icon: "hass:play", + action: "media_play", + }, + ] + : undefined; + } + + const buttons: ControlButton[] = []; + + if (supportsFeature(stateObj, SUPPORT_PREVIOUS_TRACK)) { + buttons.push({ + icon: "hass:skip-previous", + action: "media_previous_track", + }); + } + + if ( + (state === "playing" && + (supportsFeature(stateObj, SUPPORT_PAUSE) || + supportsFeature(stateObj, SUPPORT_STOP))) || + (state === "paused" && supportsFeature(stateObj, SUPPORTS_PLAY)) + ) { + buttons.push({ + icon: + state !== "playing" + ? "hass:play" + : supportsFeature(stateObj, SUPPORT_PAUSE) + ? "hass:pause" + : "hass:stop", + action: "media_play_pause", + }); + } + + if (supportsFeature(stateObj, SUPPORT_NEXT_TRACK)) { + buttons.push({ + icon: "hass:skip-next", + action: "media_next_track", + }); + } + + return buttons.length > 0 ? buttons : undefined; + } + private get _image() { if (!this.hass || !this._config) { return undefined; } - const stateObj = this.hass.states[this._config.entity] as MediaEntity; + const stateObj = this._stateObj; if (!stateObj) { return undefined; @@ -449,21 +479,20 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { } private get _showProgressBar() { - if (!this.hass || !this._config) { + if (!this.hass || !this._config || this._narrow) { return false; } - const stateObj = this.hass.states[this._config.entity] as MediaEntity; + const stateObj = this._stateObj; if (!stateObj) { return false; } return ( - !OFF_STATES.includes(stateObj.state) && - stateObj.attributes.media_duration && - stateObj.attributes.media_position && - !this._narrow + (stateObj.state === "playing" || stateObj.state === "paused") && + "media_duration" in stateObj.attributes && + "media_position" in stateObj.attributes ); } @@ -490,7 +519,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { debounce(() => this._measureCard(), 250, false) ); - this._resizeObserver.observe(this); + this._resizeObserver.observe(this.shadowRoot!.querySelector("ha-card")!); } private _handleMoreInfo(): void { @@ -500,18 +529,28 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { } private _handleClick(e: MouseEvent): void { - this.hass!.callService("media_player", (e.currentTarget! as any).action, { - entity_id: this._config!.entity, - }); + this.hass!.callService( + "media_player", + (e.currentTarget! as PaperIconButtonElement).getAttribute("action")!, + { + entity_id: this._config!.entity, + } + ); } - private _updateProgressBar(stateObj: MediaEntity): void { + private _updateProgressBar(): void { if (this._progressBar) { - this._progressBar.value = getCurrentProgress(stateObj); + this._progressBar.value = getCurrentProgress(this._stateObj!); } } - private _handleSeek(e: MouseEvent, stateObj: MediaEntity): void { + private get _stateObj(): MediaEntity | undefined { + return this.hass!.states[this._config!.entity] as MediaEntity; + } + + private _handleSeek(e: MouseEvent): void { + const stateObj = this._stateObj!; + if (!supportsFeature(stateObj, SUPPORT_SEEK)) { return; } @@ -710,9 +749,11 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { height: 44px; } - .playPauseButton { - width: 56px !important; - height: 56px !important; + paper-icon-button[action="media_play"], + paper-icon-button[action="media_play_pause"], + paper-icon-button[action="turn_on"] { + width: 56px; + height: 56px; } .top-info { @@ -742,19 +783,16 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { overflow: hidden; } + hui-marquee { + font-size: 1.2em; + margin: 0px 0 4px; + } + .title-controls { padding-top: 16px; } - .title { - font-size: 1.2em; - margin: 0px 0 4px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - - .progress { + paper-progress { width: 100%; height: var(--paper-progress-height, 4px); margin-top: 4px; @@ -775,11 +813,6 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { height: 55px; } - .off.player, - .narrow.player { - padding-bottom: 16px !important; - } - .narrow .controls, .no-progress .controls { padding-bottom: 0; @@ -790,12 +823,14 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { height: 40px; } - .narrow .playPauseButton { - width: 50px !important; - height: 50px !important; + .narrow paper-icon-button[action="media_play"], + .narrow paper-icon-button[action="media_play_pause"], + .narrow paper-icon-button[action="turn_on"] { + width: 50px; + height: 50px; } - .no-progress.player { + .no-progress.player:not(.no-controls) { padding-bottom: 0px; } `; diff --git a/src/panels/lovelace/components/hui-marquee.ts b/src/panels/lovelace/components/hui-marquee.ts index 7261aec29b..cc59cbf700 100644 --- a/src/panels/lovelace/components/hui-marquee.ts +++ b/src/panels/lovelace/components/hui-marquee.ts @@ -8,13 +8,25 @@ import { CSSResult, property, } from "lit-element"; -import { classMap } from "lit-html/directives/class-map"; @customElement("hui-marquee") class HuiMarquee extends LitElement { @property() public text?: string; - @property() public active?: boolean; - @property() private _animating = false; + @property({ type: Boolean }) public active?: boolean; + @property({ reflect: true, type: Boolean, attribute: "animating" }) + private _animating = false; + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + + this.addEventListener("mouseover", () => this.classList.add("hovering"), { + // Capture because we need to run before a parent sets active on us. + // Hovering will disable the overflow, allowing us to calc if we overflow. + capture: true, + }); + this.addEventListener("mouseout", () => this.classList.remove("hovering")); + } + protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); @@ -33,12 +45,7 @@ class HuiMarquee extends LitElement { } return html` -
+
${this.text} ${this._animating ? html` @@ -61,15 +68,29 @@ class HuiMarquee extends LitElement { display: flex; position: relative; align-items: center; - height: 25px; + height: 1em; } .marquee-inner { position: absolute; + left: 0; + right: 0; + text-overflow: ellipsis; + overflow: hidden; animation: marquee 10s linear infinite paused; } - .animating { + :host(.hovering) .marquee-inner { + text-overflow: initial; + overflow: initial; + } + + :host([animating]) .marquee-inner { + left: initial; + right: initial; + } + + :host([animating]) > div { animation-play-state: running; } diff --git a/src/panels/lovelace/entity-rows/hui-media-player-entity-row.ts b/src/panels/lovelace/entity-rows/hui-media-player-entity-row.ts index 67c112e085..721648a11d 100644 --- a/src/panels/lovelace/entity-rows/hui-media-player-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-media-player-entity-row.ts @@ -20,7 +20,6 @@ import { supportsFeature } from "../../../common/entity/supports-feature"; import { SUPPORTS_PLAY, SUPPORT_NEXT_TRACK, - OFF_STATES, SUPPORT_PAUSE, } from "../../../data/media-player"; import { hasConfigOrEntityChanged } from "../common/has-changed"; @@ -68,7 +67,7 @@ class HuiMediaPlayerEntityRow extends LitElement implements LovelaceRow { .config=${this._config} .secondaryText=${this._computeMediaTitle(stateObj)} > - ${OFF_STATES.includes(stateObj.state) + ${stateObj.state === "off" || stateObj.state === "idle" ? html`
${this.hass!.localize(`state.media_player.${stateObj.state}`) || diff --git a/src/types.ts b/src/types.ts index a91df03822..7a920a1b0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,6 +210,7 @@ export type MediaEntity = HassEntityBase & { icon?: string; entity_picture_local?: string; }; + state: "playing" | "paused" | "idle" | "off" | "unavailable" | "unknown"; }; export type InputSelectEntity = HassEntityBase & {