From fdac850a22728f6755a662668166683cb3b3028b Mon Sep 17 00:00:00 2001
From: FL550 <36160004+FL550@users.noreply.github.com>
Date: Sat, 10 Oct 2020 12:27:45 +0200
Subject: [PATCH] Includes simple "currently playing" (#112)
* Includes simple "currently playing"
---
src/editor.ts | 11 +
src/localize/languages/de.json | 3 +-
src/localize/languages/en.json | 3 +-
src/spotify-card.ts | 1903 ++++++++++++++++----------------
src/types.ts | 5 +-
5 files changed, 977 insertions(+), 948 deletions(-)
diff --git a/src/editor.ts b/src/editor.ts
index 0fa75f6..314b133 100755
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -166,6 +166,8 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor
return this.config?.filter_devices?.toString() ?? '';
case ConfigEntry.Hide_Top_Header:
return this.config?.hide_top_header ?? false;
+ case ConfigEntry.Hide_Currently_Playing:
+ return this.config?.hide_currently_playing ?? false;
default:
break;
@@ -300,6 +302,15 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor
>
+
+
+
+
| Array | Array | CurrentPlayer,
- oldVal: Array | Array | Array | CurrentPlayer
-): boolean {
- if (!oldVal || (!isCurrentPlayer(oldVal) && !isCurrentPlayer(newVal) && newVal.length != oldVal.length)) {
- return true;
- }
- for (const index in newVal) {
- if (newVal[index].id != oldVal[index].id) {
- return true;
- }
- }
- return false;
-}
-
-export function hasChangedMediaPlayer(newVal: HassEntity, oldVal: HassEntity): boolean {
- if (!oldVal) {
- return true;
- }
- if (
- newVal.state != oldVal.state ||
- newVal.attributes.shuffle != oldVal.attributes.shuffle ||
- newVal.attributes.media_title != oldVal.attributes.media_title ||
- newVal.attributes.media_artist != oldVal.attributes.media_artist ||
- newVal.attributes.volume_level != oldVal.attributes.volume_level
- ) {
- return true;
- }
- return false;
-}
-
-@customElement('spotify-card')
-export class SpotifyCard extends LitElement {
- public hass!: HomeAssistant;
-
- @property({ type: Object })
- public config!: SpotifyCardConfig;
-
- @property({ hasChanged: hasChangedCustom })
- public playlists: Array = [];
-
- @property({ hasChanged: hasChangedCustom })
- public devices: Array = [];
-
- @property({ hasChanged: hasChangedCustom })
- public chromecast_devices: Array = [];
-
- @property({ hasChanged: hasChangedCustom })
- public player?: CurrentPlayer;
-
- @internalProperty({ hasChanged: hasChangedMediaPlayer })
- private _spotify_state?: HassEntity;
-
- private spotcast_connector!: ISpotcastConnector;
- private _unsubscribe_entitites?: any;
- private _spotify_installed = false;
- private _fetch_time_out: any = 0;
-
- constructor() {
- super();
- this.spotcast_connector = new SpotcastConnector(this);
- }
-
- // Calls the editor
- public static async getConfigElement(): Promise {
- return document.createElement('spotify-card-editor') as LovelaceCardEditor;
- }
-
- // Returns default config for Lovelace picker
- public static getStubConfig(): Record {
- return {};
- }
-
- public setConfig(_config: SpotifyCardConfig): void {
- // I don't know why, but if PLAYLIST_TYPES is not used. The card gives an error which is hard to debug.
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const bug = PLAYLIST_TYPES;
- //Check for errors in config
- let var_error = '';
- if (
- _config.playlist_type &&
- !(Object.values(PlaylistType) as Array).includes(_config.playlist_type.toLowerCase())
- ) {
- var_error = 'playlist_type';
- }
- if (
- _config.display_style &&
- !(Object.values(DisplayStyle) as Array).includes(_config.display_style.toLowerCase())
- ) {
- var_error = 'display_style';
- }
- // Show error if neccessary
- if (_config.show_error || var_error != '') {
- throw new Error(localize('common.invalid_configuration') + ': ' + var_error);
- }
- this.config = _config;
- }
-
- connectedCallback(): void {
- super.connectedCallback();
- this.doSubscribeEntities();
- this.updateSpotcast();
- }
-
- public doSubscribeEntities(): void {
- if (this.hass?.connection && !this._unsubscribe_entitites && this.isConnected) {
- this._unsubscribe_entitites = subscribeEntities(this.hass.connection, (entities) =>
- this.entitiesUpdated(entities)
- );
- }
- }
-
- //Callback when hass-entity has changed
- private entitiesUpdated(entities: HassEntities): void {
- let updateDevices = false;
- for (const item in entities) {
- // Are there any changes to media players
- if (item.startsWith('media_player')) {
- // Get spotify state
- if (item.startsWith('media_player.spotify') || item == this.config.spotify_entity) {
- this._spotify_installed = true;
- this._spotify_state = entities[item];
- }
- updateDevices = true;
- }
- }
- if (updateDevices && !document.hidden) {
- this.updateSpotcast();
- }
- }
-
- private updateSpotcast(): void {
- // Debounce updates to 500ms
- if (this._fetch_time_out) {
- clearTimeout(this._fetch_time_out);
- }
- this._fetch_time_out = setTimeout(async () => {
- if (this.hass) {
- //request update of spotcast data
- if (this.isSpotcastInstalled() && !this.spotcast_connector.is_loading()) {
- await this.spotcast_connector.updateState();
- await this.spotcast_connector.fetchPlaylists();
- }
- }
- }, 500);
- }
-
- public disconnectedCallback(): void {
- super.disconnectedCallback();
- if (this._unsubscribe_entitites) {
- this._unsubscribe_entitites();
- this._unsubscribe_entitites = undefined;
- }
- }
-
- protected updated(changedProps: PropertyValues): void {
- super.updated(changedProps);
- this.updateComplete.then(() => {
- for (const cover of this.renderRoot.querySelectorAll('[data-spotify-image-url]') as NodeListOf) {
- const downloadingImage = new Image();
- downloadingImage.onload = function (event) {
- cover.firstElementChild?.replaceWith(event.srcElement as HTMLDivElement);
- };
- cover.dataset.spotifyImageUrl ? (downloadingImage.src = cover.dataset.spotifyImageUrl) : '';
- }
- });
- }
-
- private getDisplayStyle(): DisplayStyle {
- // Display spotify playlists
- if (this.config.display_style?.toLowerCase() == 'grid') {
- return DisplayStyle.Grid;
- } else {
- return DisplayStyle.List;
- }
- }
-
- private getPlayingState(): boolean {
- return this._spotify_state?.state == 'playing' ?? false;
- }
-
- private getShuffleState(): boolean {
- return this.player?.shuffle_state ?? false;
- }
-
- public getSpotifyEntityState(): string {
- return this._spotify_state ? this._spotify_state.state : '';
- }
-
- private isSpotcastInstalled(): boolean {
- if (this.hass?.connection && servicesColl(this.hass.connection).state.spotcast !== undefined) {
- return true;
- }
- return false;
- }
-
- private checkIfAllowedToShow(device: ConnectDevice | ChromecastDevice): boolean {
- const filters =
- this.config.filter_devices?.map((filter_str) => {
- return new RegExp(filter_str + '$');
- }) ?? [];
- for (const filter of filters) {
- if (filter.test(isConnectDevice(device) ? device.name : device.friendly_name)) {
- return false;
- }
- }
- return true;
- }
-
- private getDefaultDevice(): string | undefined {
- let [spotify_connect_devices, chromecast_devices] = this.getFilteredDevices();
- spotify_connect_devices = spotify_connect_devices.filter((device) => {
- return device.name == this.config.default_device;
- });
- chromecast_devices = chromecast_devices.filter((device) => {
- return device.friendly_name == this.config.default_device;
- });
- if (spotify_connect_devices.length > 0 || chromecast_devices.length > 0) {
- return this.config.default_device;
- }
- return;
- }
-
- private getFilteredDevices(): [ConnectDevice[], ChromecastDevice[]] {
- const spotify_connect_devices = this.devices.filter(this.checkIfAllowedToShow, this);
- const chromecast_devices = this.chromecast_devices.filter(this.checkIfAllowedToShow, this);
- return [spotify_connect_devices, chromecast_devices];
- }
-
- private getPlaylists(): Playlist[] {
- return this.playlists;
- }
-
- private isThisPlaylistPlaying(item: Playlist): boolean {
- return this._spotify_state?.attributes.media_playlist === item.name;
- }
-
- private startUri(elem: MouseEvent, uri: string): void {
- const loading = 'loading';
- const target = elem.target as HTMLElement;
- let item;
- switch (target.localName) {
- case 'img': {
- item = target.parentElement?.parentElement;
- break;
- }
- case 'div': {
- item = target;
- break;
- }
- case 'p': {
- item = target.parentElement;
- break;
- }
- default: {
- console.log(target);
- break;
- }
- }
- item.classList.add(loading);
- setTimeout(() => {
- item.classList.remove(loading);
- }, 10000);
- this.spotcast_connector.playUri(uri);
- }
-
- private onShuffleSelect(elem: MouseEvent): void {
- this.hass.callService('media_player', 'shuffle_set', {
- entity_id: this._spotify_state?.entity_id,
- shuffle: !this.player?.shuffle_state,
- });
- const target = elem.target as HTMLElement;
- let parent = target.parentElement;
- if (parent?.localName == 'svg') {
- parent = parent.parentElement;
- }
- if (parent?.classList.contains('shuffle')) {
- parent.classList.remove('shuffle');
- } else {
- parent?.classList.add('shuffle');
- }
- }
-
- private handleMediaEvent(ev: Event, command: string): void {
- ev.stopPropagation();
- if (this._spotify_state) {
- this.hass.callService('media_player', command, {
- entity_id: this._spotify_state.entity_id,
- });
- }
- }
-
- private getVolume(): number {
- return this._spotify_state?.attributes?.volume_level * 100;
- }
-
- private handleVolumeChanged(ev: ValueChangedEvent): void {
- //Prevents volume setting directly after page load
- if (this._spotify_state && ev.timeStamp > 2500) {
- this.hass.callService('media_player', 'volume_set', {
- entity_id: this._spotify_state.entity_id,
- volume_level: ev.target.value / 100,
- });
- }
- }
-
- private confirmDeviceSelection(elem: MouseEvent) {
- const target = elem.target as HTMLElement;
- target?.parentElement?.classList.add('dropdown-content-hide');
- setTimeout(() => {
- target?.parentElement?.classList.remove('dropdown-content-hide');
- }, 1000);
- }
-
- private spotifyDeviceSelected(elem: MouseEvent, device: ConnectDevice): void {
- this.confirmDeviceSelection(elem);
- const current_player = this.spotcast_connector.getCurrentPlayer();
- if (current_player) {
- return this.spotcast_connector.transferPlaybackToConnectDevice(device.id);
- }
- const playlist = this.playlists[0];
- console.log('spotifyDeviceSelected playing first playlist');
- this.spotcast_connector.playUriOnConnectDevice(device.id, playlist.uri);
- }
-
- private chromecastDeviceSelected(elem: MouseEvent, device: ChromecastDevice): void {
- this.confirmDeviceSelection(elem);
- const current_player = this.spotcast_connector.getCurrentPlayer();
- if (current_player) {
- return this.spotcast_connector.transferPlaybackToCastDevice(device.friendly_name);
- }
-
- const playlist = this.playlists[0];
- console.log('chromecastDeviceSelected playing first playlist');
- this.spotcast_connector.playUriOnCastDevice(device.friendly_name, playlist.uri);
- }
-
- private getCurrentPlayer(): ConnectDevice | undefined {
- return this.spotcast_connector.getCurrentPlayer();
- }
-
- protected render(): TemplateResult | void {
- let warning = html``;
- if (!this.isSpotcastInstalled()) {
- warning = this.showWarning(localize('common.show_missing_spotcast'));
- }
- // if (!this._spotify_installed) {
- // warning = this.showWarning(localize('common.show_missing_spotify'));
- // }
-
- // Display loading screen if no content available yet
- let content = html`Loading...
`;
- // Request playlist data if not loaded
- if (this.spotcast_connector.is_loaded()) {
- switch (this.getDisplayStyle()) {
- case DisplayStyle.Grid: {
- content = this.generateGridView();
- break;
- }
- default: {
- content = this.generateListView();
- break;
- }
- }
- }
- const header = html``;
-
- return html`
- ${this.config.hide_warning ? '' : warning} ${!this.config.hide_top_header ? header : null}
- ${content}
-
-
- `;
- }
-
- // Generate device list
- private generateDeviceList(): TemplateResult {
- const [spotify_connect_devices, chromecast_devices] = this.getFilteredDevices();
- if (spotify_connect_devices.length == 0 && chromecast_devices.length == 0) {
- return html`No devices found
`;
- }
- return html`
- ${spotify_connect_devices.length > 0 ? html`Spotify Connect devices
` : null}
- ${spotify_connect_devices.map((device) => {
- return html` this.spotifyDeviceSelected(elem, device)}>${device.name}`;
- })}
- ${chromecast_devices.length > 0 ? html`Chromecast devices
` : null}
- ${chromecast_devices.map((device) => {
- return html` this.chromecastDeviceSelected(elem, device)}>${device.friendly_name}`;
- })}
- `;
- }
-
- // Generate items for display style 'List'
- public generateListView(): TemplateResult {
- const result: TemplateResult[] = [];
- const playlists = this.getPlaylists();
- for (let i = 0; i < playlists.length; i++) {
- const item = playlists[i];
- result.push(html` this.startUri(elem, item.uri)}
- >
-
-
${item.name}
-
`);
- }
- return html`${result}
`;
- }
-
- // Generate items for display style 'Grid'
- public generateGridView(): TemplateResult {
- const result: TemplateResult[] = [];
- const playlists = this.getPlaylists();
- for (let i = 0; i < playlists.length; i++) {
- const item = playlists[i];
- this._spotify_state?.attributes.media_playlist === item.name;
- result.push(html` this.startUri(elem, item.uri)}>
-
-
`);
- }
-
- const configured_grid_width = this.config.grid_covers_per_row ? this.config.grid_covers_per_row : 3;
- const grid_width = (100 - 10) / configured_grid_width;
-
- return html`
- ${result}
-
`;
- }
-
- private onPauseSelect(ev: Event): void {
- this.handleMediaEvent(ev, 'media_pause');
- }
-
- private onResumeSelect(ev: Event): void {
- this.handleMediaEvent(ev, 'media_play');
- }
-
- private onNextSelect(ev: Event): void {
- this.handleMediaEvent(ev, 'media_next_track');
- }
-
- private onPrevSelect(ev: Event): void {
- this.handleMediaEvent(ev, 'media_previous_track');
- }
-
- // Show warning on top of the card
- private showWarning(warning: string): TemplateResult {
- return html`${warning}`;
- }
-
- static get styles(): CSSResult[] {
- return [SpotifyCard.generalStyles, SpotifyCard.listStyles, SpotifyCard.gridStyles];
- }
-
- static generalStyles = css`
- *:focus {
- outline: none;
- }
-
- ha-card {
- --header-height: 2.5em;
- --footer-height: 2.5em;
- padding: 0.5em;
- display: flex;
- flex-direction: column;
- }
-
- hui-warning {
- position: absolute;
- right: 0;
- left: 0;
- text-align: center;
- }
-
- #header {
- display: flex;
- height: var(--header-height);
- padding-bottom: 0.5em;
- }
- #header > * {
- display: flex;
- flex: 1;
- align-items: center;
- }
-
- #content {
- border: solid 2px var(--divider-color);
- border-radius: 0.2em;
- overflow: auto;
- padding: 0.2em;
- background-color: var(--primary-background-color);
- }
-
- #icon {
- justify-content: left;
- padding-left: 0.5em;
- cursor: pointer;
- }
-
- #icon svg {
- width: 100px;
- fill: var(--primary-text-color);
- }
-
- #header_name {
- font-size: x-large;
- justify-content: center;
- }
-
- #footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: var(--footer-height);
- padding: 0.5rem;
- padding-bottom: 0;
- }
-
- .footer__right {
- display: flex;
- align-items: center;
- }
-
- .playback-controls {
- display: flex;
- }
-
- .playback-controls > div {
- height: 2.5em;
- }
-
- .playback-controls > div:first-child {
- padding-left: 0;
- }
-
- .playback-controls svg {
- height: 100%;
- cursor: pointer;
- }
-
- .shuffle > svg {
- fill: var(--primary-color);
- }
-
- #volume-slider {
- bottom: 3em;
- right: 0.5em;
- }
-
- #volume-slider > * {
- height: 2.5em;
- }
-
- .dropdown {
- display: none;
- position: absolute;
- box-shadow: var(--primary-text-color) 0 0 16px 0px;
- z-index: 1;
- background-color: var(--card-background-color);
- }
-
- .volume:hover + #volume-slider,
- #volume-slider:hover {
- display: block;
- }
-
- .small-icon {
- display: inline-flex;
- align-items: center;
- cursor: pointer;
- padding-left: 10px;
- }
-
- .small-icon svg {
- height: 2em;
- }
-
- .controls {
- display: flex;
- }
-
- .mediaplayer {
- position: relative;
- display: inline-block;
- }
-
- .mediaplayer_select {
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .mediaplayer_speaker_icon {
- display: inline-block;
- padding: 3px;
- width: 17px;
- height: 17px;
- margin-right: 10px;
- border: thin solid var(--primary-text-color);
- border-radius: 50%;
- }
-
- .dropdown-wrapper {
- display: contents;
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- }
-
- .dropdown-content {
- left: 1em;
- bottom: 0.5em;
- max-height: calc(100% - 1em);
- overflow-y: auto;
- min-width: 250px;
- }
-
- .dropdown-content p {
- font-weight: bold;
- padding: 0.5em;
- line-height: 1.5em;
- margin: 0;
- }
-
- .dropdown-content a {
- color: var(--primary-text-color);
- padding: 12px 16px;
- text-decoration: none;
- display: block;
- }
- .dropdown-content a:hover {
- box-shadow: inset 0 0 100px 100px var(--secondary-background-color);
- }
- .controls:hover + .dropdown-content:not(.dropdown-content-hide),
- .dropdown-content:hover:not(.dropdown-content-hide) {
- display: block;
- }
-
- svg {
- fill: var(--primary-text-color);
- }
-
- @keyframes loading-grid {
- 0% {
- box-shadow: rgba(var(--rgb-accent-color), 1) 0 0 0.2em 0.2em;
- }
- 50% {
- box-shadow: rgba(var(--rgb-accent-color), 0) 0 0 0.2em 0.2em;
- }
- 100% {
- box-shadow: rgba(var(--rgb-accent-color), 1) 0 0 0.2em 0.2em;
- }
- }
-
- @keyframes loading-list {
- 0% {
- background-color: rgba(var(--rgb-accent-color), 1);
- }
- 50% {
- background-color: rgba(var(--rgb-accent-color), 0);
- }
- 100% {
- background-color: rgba(var(--rgb-accent-color), 1);
- }
- }
-
- .loading {
- animation-duration: 1.5s;
- animation-iteration-count: 5;
- animation-timing-function: ease-in-out;
- }
-
- .grid-item.loading {
- animation-name: loading-grid;
- }
-
- .list-item.loading {
- animation-name: loading-list;
- }
- `;
-
- //Style definition for the List view
- static listStyles = css`
- ha-card {
- --list-item-height: 3em;
- --placeholder-padding: 4px;
- }
-
- .list-item {
- /* height: var(--list-item-height); */
- align-items: center;
- border-bottom: solid var(--divider-color) 1px;
- display: flex;
- cursor: pointer;
- margin-right: -0.2em;
- background-clip: content-box;
- }
-
- .list-item:hover {
- background-color: var(--secondary-background-color);
- }
-
- .list-item:last-of-type {
- border-bottom: 0;
- }
-
- .list-item.playing {
- background-color: var(--primary-color);
- }
-
- .cover {
- }
-
- .list-item .cover {
- height: var(--list-item-height);
- object-fit: contain;
- }
-
- .cover > img {
- height: 100%;
- max-width: var(--list-item-height); /* enforce square playlist icons */
- }
-
- .cover > svg {
- height: calc(var(--list-item-height) - var(--placeholder-padding));
- margin: calc(var(--placeholder-padding) / 2);
- }
-
- .list-item > p {
- margin: 0 0.5em 0 0.5em;
- }
- `;
-
- //Style definition for the Grid view
- static gridStyles = css`
- .grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(30%, 1fr));
- grid-gap: 0.5em;
- }
-
- .grid-item {
- position: relative;
- cursor: pointer;
- box-shadow: var(--primary-text-color) 0 0 0.2em;
- }
-
- .grid-item:hover {
- box-shadow: rgba(var(--rgb-accent-color), 1) 0 0 0.2em 0.2em;
- }
-
- .grid-item-album-image {
- width: 100%;
- display: grid;
- }
-
- .grid-item-album-image > img {
- width: 100%;
- }
-
- .grid-item-album-image > svg {
- width: 100%;
- margin: 10px;
- }
-
- .grid-item-album-image.playing {
- box-shadow: var(--primary-color) 0 0 0.2em 0.2em;
- }
- `;
-}
+import {
+ LitElement,
+ html,
+ customElement,
+ property,
+ internalProperty,
+ CSSResult,
+ TemplateResult,
+ PropertyValues,
+ css,
+} from 'lit-element';
+
+import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers';
+
+import { CARD_VERSION } from './const';
+
+import {
+ SpotifyCardConfig,
+ DisplayStyle,
+ PlaylistType,
+ isConnectDevice,
+ ConnectDevice,
+ ChromecastDevice,
+ Playlist,
+ CurrentPlayer,
+ isCurrentPlayer,
+ ValueChangedEvent,
+} from './types';
+
+import { PLAYLIST_TYPES } from './editor';
+
+import { localize } from './localize/localize';
+import { ISpotcastConnector, SpotcastConnector } from './spotcast-connector';
+import { HassEntity, servicesColl, subscribeEntities, HassEntities } from 'home-assistant-js-websocket';
+
+// Display card version in console
+/* eslint no-console: 0 */
+console.info(
+ `%c SPOTIFY-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `,
+ 'color: orange; font-weight: bold; background: black',
+ 'color: white; font-weight: bold; background: dimgray'
+);
+
+// Configures the preview in the Lovelace card picker
+(window as any).customCards = (window as any).customCards || [];
+(window as any).customCards.push({
+ type: 'spotify-card',
+ name: 'Spotify Card',
+ description: localize('common.description'),
+ preview: true,
+});
+
+export function hasChangedCustom(
+ newVal: Array | Array | Array | CurrentPlayer,
+ oldVal: Array | Array | Array | CurrentPlayer
+): boolean {
+ if (!oldVal || (!isCurrentPlayer(oldVal) && !isCurrentPlayer(newVal) && newVal.length != oldVal.length)) {
+ return true;
+ }
+ for (const index in newVal) {
+ if (newVal[index].id != oldVal[index].id) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function hasChangedMediaPlayer(newVal: HassEntity, oldVal: HassEntity): boolean {
+ if (!oldVal) {
+ return true;
+ }
+ if (
+ newVal.state != oldVal.state ||
+ newVal.attributes.shuffle != oldVal.attributes.shuffle ||
+ newVal.attributes.media_title != oldVal.attributes.media_title ||
+ newVal.attributes.media_artist != oldVal.attributes.media_artist ||
+ newVal.attributes.volume_level != oldVal.attributes.volume_level
+ ) {
+ return true;
+ }
+ return false;
+}
+
+@customElement('spotify-card')
+export class SpotifyCard extends LitElement {
+ public hass!: HomeAssistant;
+
+ @property({ type: Object })
+ public config!: SpotifyCardConfig;
+
+ @property({ hasChanged: hasChangedCustom })
+ public playlists: Array = [];
+
+ @property({ hasChanged: hasChangedCustom })
+ public devices: Array = [];
+
+ @property({ hasChanged: hasChangedCustom })
+ public chromecast_devices: Array = [];
+
+ @property({ hasChanged: hasChangedCustom })
+ public player?: CurrentPlayer;
+
+ @internalProperty({ hasChanged: hasChangedMediaPlayer })
+ private _spotify_state?: HassEntity;
+
+ private spotcast_connector!: ISpotcastConnector;
+ private _unsubscribe_entitites?: any;
+ private _spotify_installed = false;
+ private _fetch_time_out: any = 0;
+
+ constructor() {
+ super();
+ this.spotcast_connector = new SpotcastConnector(this);
+ }
+
+ // Calls the editor
+ public static async getConfigElement(): Promise {
+ return document.createElement('spotify-card-editor') as LovelaceCardEditor;
+ }
+
+ // Returns default config for Lovelace picker
+ public static getStubConfig(): Record {
+ return {};
+ }
+
+ public setConfig(_config: SpotifyCardConfig): void {
+ // I don't know why, but if PLAYLIST_TYPES is not used. The card gives an error which is hard to debug.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const bug = PLAYLIST_TYPES;
+ //Check for errors in config
+ let var_error = '';
+ if (
+ _config.playlist_type &&
+ !(Object.values(PlaylistType) as Array).includes(_config.playlist_type.toLowerCase())
+ ) {
+ var_error = 'playlist_type';
+ }
+ if (
+ _config.display_style &&
+ !(Object.values(DisplayStyle) as Array).includes(_config.display_style.toLowerCase())
+ ) {
+ var_error = 'display_style';
+ }
+ // Show error if neccessary
+ if (_config.show_error || var_error != '') {
+ throw new Error(localize('common.invalid_configuration') + ': ' + var_error);
+ }
+ this.config = _config;
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ this.doSubscribeEntities();
+ this.updateSpotcast();
+ }
+
+ public doSubscribeEntities(): void {
+ if (this.hass?.connection && !this._unsubscribe_entitites && this.isConnected) {
+ this._unsubscribe_entitites = subscribeEntities(this.hass.connection, (entities) =>
+ this.entitiesUpdated(entities)
+ );
+ }
+ }
+
+ //Callback when hass-entity has changed
+ private entitiesUpdated(entities: HassEntities): void {
+ let updateDevices = false;
+ for (const item in entities) {
+ // Are there any changes to media players
+ if (item.startsWith('media_player')) {
+ // Get spotify state
+ if (item.startsWith('media_player.spotify') || item == this.config.spotify_entity) {
+ this._spotify_installed = true;
+ this._spotify_state = entities[item];
+ }
+ updateDevices = true;
+ }
+ }
+ if (updateDevices && !document.hidden) {
+ this.updateSpotcast();
+ }
+ }
+
+ private updateSpotcast(): void {
+ // Debounce updates to 500ms
+ if (this._fetch_time_out) {
+ clearTimeout(this._fetch_time_out);
+ }
+ this._fetch_time_out = setTimeout(async () => {
+ if (this.hass) {
+ //request update of spotcast data
+ if (this.isSpotcastInstalled() && !this.spotcast_connector.is_loading()) {
+ await this.spotcast_connector.updateState();
+ await this.spotcast_connector.fetchPlaylists();
+ }
+ }
+ }, 500);
+ }
+
+ public disconnectedCallback(): void {
+ super.disconnectedCallback();
+ if (this._unsubscribe_entitites) {
+ this._unsubscribe_entitites();
+ this._unsubscribe_entitites = undefined;
+ }
+ }
+
+ protected updated(changedProps: PropertyValues): void {
+ super.updated(changedProps);
+ this.updateComplete.then(() => {
+ for (const cover of this.renderRoot.querySelectorAll('[data-spotify-image-url]') as NodeListOf) {
+ const downloadingImage = new Image();
+ downloadingImage.onload = function (event) {
+ cover.firstElementChild?.replaceWith(event.srcElement as HTMLDivElement);
+ };
+ cover.dataset.spotifyImageUrl ? (downloadingImage.src = cover.dataset.spotifyImageUrl) : '';
+ }
+ });
+ }
+
+ private getDisplayStyle(): DisplayStyle {
+ // Display spotify playlists
+ if (this.config.display_style?.toLowerCase() == 'grid') {
+ return DisplayStyle.Grid;
+ } else {
+ return DisplayStyle.List;
+ }
+ }
+
+ private getPlayingState(): boolean {
+ return this._spotify_state?.state == 'playing' ?? false;
+ }
+
+ private getShuffleState(): boolean {
+ return this.player?.shuffle_state ?? false;
+ }
+
+ public getSpotifyEntityState(): string {
+ return this._spotify_state ? this._spotify_state.state : '';
+ }
+
+ private isSpotcastInstalled(): boolean {
+ if (this.hass?.connection && servicesColl(this.hass.connection).state.spotcast !== undefined) {
+ return true;
+ }
+ return false;
+ }
+
+ private checkIfAllowedToShow(device: ConnectDevice | ChromecastDevice): boolean {
+ const filters =
+ this.config.filter_devices?.map((filter_str) => {
+ return new RegExp(filter_str + '$');
+ }) ?? [];
+ for (const filter of filters) {
+ if (filter.test(isConnectDevice(device) ? device.name : device.friendly_name)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private getDefaultDevice(): string | undefined {
+ let [spotify_connect_devices, chromecast_devices] = this.getFilteredDevices();
+ spotify_connect_devices = spotify_connect_devices.filter((device) => {
+ return device.name == this.config.default_device;
+ });
+ chromecast_devices = chromecast_devices.filter((device) => {
+ return device.friendly_name == this.config.default_device;
+ });
+ if (spotify_connect_devices.length > 0 || chromecast_devices.length > 0) {
+ return this.config.default_device;
+ }
+ return;
+ }
+
+ private getFilteredDevices(): [ConnectDevice[], ChromecastDevice[]] {
+ const spotify_connect_devices = this.devices.filter(this.checkIfAllowedToShow, this);
+ const chromecast_devices = this.chromecast_devices.filter(this.checkIfAllowedToShow, this);
+ return [spotify_connect_devices, chromecast_devices];
+ }
+
+ private getPlaylists(): Playlist[] {
+ return this.playlists;
+ }
+
+ private isThisPlaylistPlaying(item: Playlist): boolean {
+ return this._spotify_state?.attributes.media_playlist === item.name;
+ }
+
+ private startUri(elem: MouseEvent, uri: string): void {
+ const loading = 'loading';
+ const target = elem.target as HTMLElement;
+ let item;
+ switch (target.localName) {
+ case 'img': {
+ item = target.parentElement?.parentElement;
+ break;
+ }
+ case 'div': {
+ item = target;
+ break;
+ }
+ case 'p': {
+ item = target.parentElement;
+ break;
+ }
+ default: {
+ console.log(target);
+ break;
+ }
+ }
+ item.classList.add(loading);
+ setTimeout(() => {
+ item.classList.remove(loading);
+ }, 10000);
+ this.spotcast_connector.playUri(uri);
+ }
+
+ private onShuffleSelect(elem: MouseEvent): void {
+ this.hass.callService('media_player', 'shuffle_set', {
+ entity_id: this._spotify_state?.entity_id,
+ shuffle: !this.player?.shuffle_state,
+ });
+ const target = elem.target as HTMLElement;
+ let parent = target.parentElement;
+ if (parent?.localName == 'svg') {
+ parent = parent.parentElement;
+ }
+ if (parent?.classList.contains('shuffle')) {
+ parent.classList.remove('shuffle');
+ } else {
+ parent?.classList.add('shuffle');
+ }
+ }
+
+ private handleMediaEvent(ev: Event, command: string): void {
+ ev.stopPropagation();
+ if (this._spotify_state) {
+ this.hass.callService('media_player', command, {
+ entity_id: this._spotify_state.entity_id,
+ });
+ }
+ }
+
+ private getVolume(): number {
+ return this._spotify_state?.attributes?.volume_level * 100;
+ }
+
+ private handleVolumeChanged(ev: ValueChangedEvent): void {
+ //Prevents volume setting directly after page load
+ if (this._spotify_state && ev.timeStamp > 2500) {
+ this.hass.callService('media_player', 'volume_set', {
+ entity_id: this._spotify_state.entity_id,
+ volume_level: ev.target.value / 100,
+ });
+ }
+ }
+
+ private confirmDeviceSelection(elem: MouseEvent) {
+ const target = elem.target as HTMLElement;
+ target?.parentElement?.classList.add('dropdown-content-hide');
+ setTimeout(() => {
+ target?.parentElement?.classList.remove('dropdown-content-hide');
+ }, 1000);
+ }
+
+ private spotifyDeviceSelected(elem: MouseEvent, device: ConnectDevice): void {
+ this.confirmDeviceSelection(elem);
+ const current_player = this.spotcast_connector.getCurrentPlayer();
+ if (current_player) {
+ return this.spotcast_connector.transferPlaybackToConnectDevice(device.id);
+ }
+ const playlist = this.playlists[0];
+ console.log('spotifyDeviceSelected playing first playlist');
+ this.spotcast_connector.playUriOnConnectDevice(device.id, playlist.uri);
+ }
+
+ private chromecastDeviceSelected(elem: MouseEvent, device: ChromecastDevice): void {
+ this.confirmDeviceSelection(elem);
+ const current_player = this.spotcast_connector.getCurrentPlayer();
+ if (current_player) {
+ return this.spotcast_connector.transferPlaybackToCastDevice(device.friendly_name);
+ }
+
+ const playlist = this.playlists[0];
+ console.log('chromecastDeviceSelected playing first playlist');
+ this.spotcast_connector.playUriOnCastDevice(device.friendly_name, playlist.uri);
+ }
+
+ private getCurrentPlayer(): ConnectDevice | undefined {
+ return this.spotcast_connector.getCurrentPlayer();
+ }
+
+ protected render(): TemplateResult | void {
+ let warning = html``;
+ if (!this.isSpotcastInstalled()) {
+ warning = this.showWarning(localize('common.show_missing_spotcast'));
+ }
+ // if (!this._spotify_installed) {
+ // warning = this.showWarning(localize('common.show_missing_spotify'));
+ // }
+
+ // Display loading screen if no content available yet
+ let content = html`Loading...
`;
+ // Request playlist data if not loaded
+ if (this.spotcast_connector.is_loaded()) {
+ switch (this.getDisplayStyle()) {
+ case DisplayStyle.Grid: {
+ content = this.generateGridView();
+ break;
+ }
+ default: {
+ content = this.generateListView();
+ break;
+ }
+ }
+ }
+ const header = html``;
+
+ return html`
+ ${this.config.hide_warning ? '' : warning} ${!this.config.hide_top_header ? header : null}
+ ${
+ this._spotify_state && !this.config.hide_currently_playing
+ ? html` `
+ : null
+ }
+ ${content}
+
+
+ `;
+ }
+
+ // Generate device list
+ private generateDeviceList(): TemplateResult {
+ const [spotify_connect_devices, chromecast_devices] = this.getFilteredDevices();
+ if (spotify_connect_devices.length == 0 && chromecast_devices.length == 0) {
+ return html`No devices found
`;
+ }
+ return html`
+ ${spotify_connect_devices.length > 0 ? html`Spotify Connect devices
` : null}
+ ${spotify_connect_devices.map((device) => {
+ return html` this.spotifyDeviceSelected(elem, device)}>${device.name}`;
+ })}
+ ${chromecast_devices.length > 0 ? html`Chromecast devices
` : null}
+ ${chromecast_devices.map((device) => {
+ return html` this.chromecastDeviceSelected(elem, device)}>${device.friendly_name}`;
+ })}
+ `;
+ }
+
+ // Generate items for display style 'List'
+ public generateListView(): TemplateResult {
+ const result: TemplateResult[] = [];
+ const playlists = this.getPlaylists();
+ for (let i = 0; i < playlists.length; i++) {
+ const item = playlists[i];
+ result.push(html` this.startUri(elem, item.uri)}
+ >
+
+
${item.name}
+
`);
+ }
+ return html`${result}
`;
+ }
+
+ // Generate items for display style 'Grid'
+ public generateGridView(): TemplateResult {
+ const result: TemplateResult[] = [];
+ const playlists = this.getPlaylists();
+ for (let i = 0; i < playlists.length; i++) {
+ const item = playlists[i];
+ this._spotify_state?.attributes.media_playlist === item.name;
+ result.push(html` this.startUri(elem, item.uri)}>
+
+
`);
+ }
+
+ const configured_grid_width = this.config.grid_covers_per_row ? this.config.grid_covers_per_row : 3;
+ const grid_width = (100 - 10) / configured_grid_width;
+
+ return html`
+ ${result}
+
`;
+ }
+
+ private onPauseSelect(ev: Event): void {
+ this.handleMediaEvent(ev, 'media_pause');
+ }
+
+ private onResumeSelect(ev: Event): void {
+ this.handleMediaEvent(ev, 'media_play');
+ }
+
+ private onNextSelect(ev: Event): void {
+ this.handleMediaEvent(ev, 'media_next_track');
+ }
+
+ private onPrevSelect(ev: Event): void {
+ this.handleMediaEvent(ev, 'media_previous_track');
+ }
+
+ // Show warning on top of the card
+ private showWarning(warning: string): TemplateResult {
+ return html`${warning}`;
+ }
+
+ static get styles(): CSSResult[] {
+ return [SpotifyCard.generalStyles, SpotifyCard.listStyles, SpotifyCard.gridStyles];
+ }
+
+ static generalStyles = css`
+ *:focus {
+ outline: none;
+ }
+
+ ha-card {
+ --header-height: 2.5em;
+ --footer-height: 2.5em;
+ padding: 0.5em;
+ display: flex;
+ flex-direction: column;
+ }
+
+ hui-warning {
+ position: absolute;
+ right: 0;
+ left: 0;
+ text-align: center;
+ }
+
+ #header {
+ display: flex;
+ height: var(--header-height);
+ padding-bottom: 0.5em;
+ }
+ #header > * {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ }
+
+ #header-track {
+ overflow: hidden;
+ margin: 0;
+ margin-left: 0.2em;
+ }
+
+ #content {
+ border: solid 2px var(--divider-color);
+ border-radius: 0.2em;
+ overflow: auto;
+ padding: 0.2em;
+ background-color: var(--primary-background-color);
+ }
+
+ #icon {
+ justify-content: left;
+ padding-left: 0.5em;
+ cursor: pointer;
+ }
+
+ #icon svg {
+ width: 100px;
+ fill: var(--primary-text-color);
+ }
+
+ #header_name {
+ font-size: x-large;
+ justify-content: center;
+ }
+
+ #footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: var(--footer-height);
+ padding: 0.5rem;
+ padding-bottom: 0;
+ }
+
+ .footer__right {
+ display: flex;
+ align-items: center;
+ }
+
+ .playback-controls {
+ display: flex;
+ }
+
+ .playback-controls > div {
+ height: 2.5em;
+ }
+
+ .playback-controls > div:first-child {
+ padding-left: 0;
+ }
+
+ .playback-controls svg {
+ height: 100%;
+ cursor: pointer;
+ }
+
+ .shuffle > svg {
+ fill: var(--primary-color);
+ }
+
+ #volume-slider {
+ bottom: 3em;
+ right: 0.5em;
+ }
+
+ #volume-slider > * {
+ height: 2.5em;
+ }
+
+ .dropdown {
+ display: none;
+ position: absolute;
+ box-shadow: var(--primary-text-color) 0 0 16px 0px;
+ z-index: 1;
+ background-color: var(--card-background-color);
+ }
+
+ .volume:hover + #volume-slider,
+ #volume-slider:hover {
+ display: block;
+ }
+
+ .small-icon {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ padding-left: 10px;
+ }
+
+ .small-icon svg {
+ height: 2em;
+ }
+
+ .controls {
+ display: flex;
+ }
+
+ .mediaplayer {
+ position: relative;
+ display: inline-block;
+ }
+
+ .mediaplayer_select {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .mediaplayer_speaker_icon {
+ display: inline-block;
+ padding: 3px;
+ width: 17px;
+ height: 17px;
+ margin-right: 10px;
+ border: thin solid var(--primary-text-color);
+ border-radius: 50%;
+ }
+
+ .dropdown-wrapper {
+ display: contents;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ }
+
+ .dropdown-content {
+ left: 1em;
+ bottom: 0.5em;
+ max-height: calc(100% - 1em);
+ overflow-y: auto;
+ min-width: 250px;
+ }
+
+ .dropdown-content p {
+ font-weight: bold;
+ padding: 0.5em;
+ line-height: 1.5em;
+ margin: 0;
+ }
+
+ .dropdown-content a {
+ color: var(--primary-text-color);
+ padding: 12px 16px;
+ text-decoration: none;
+ display: block;
+ }
+ .dropdown-content a:hover {
+ box-shadow: inset 0 0 100px 100px var(--secondary-background-color);
+ }
+ .controls:hover + .dropdown-content:not(.dropdown-content-hide),
+ .dropdown-content:hover:not(.dropdown-content-hide) {
+ display: block;
+ }
+
+ svg {
+ fill: var(--primary-text-color);
+ }
+
+ @keyframes loading-grid {
+ 0% {
+ box-shadow: rgba(var(--rgb-accent-color), 1) 0 0 0.2em 0.2em;
+ }
+ 50% {
+ box-shadow: rgba(var(--rgb-accent-color), 0) 0 0 0.2em 0.2em;
+ }
+ 100% {
+ box-shadow: rgba(var(--rgb-accent-color), 1) 0 0 0.2em 0.2em;
+ }
+ }
+
+ @keyframes loading-list {
+ 0% {
+ background-color: rgba(var(--rgb-accent-color), 1);
+ }
+ 50% {
+ background-color: rgba(var(--rgb-accent-color), 0);
+ }
+ 100% {
+ background-color: rgba(var(--rgb-accent-color), 1);
+ }
+ }
+
+ .loading {
+ animation-duration: 1.5s;
+ animation-iteration-count: 5;
+ animation-timing-function: ease-in-out;
+ }
+
+ .grid-item.loading {
+ animation-name: loading-grid;
+ }
+
+ .list-item.loading {
+ animation-name: loading-list;
+ }
+ `;
+
+ //Style definition for the List view
+ static listStyles = css`
+ ha-card {
+ --list-item-height: 3em;
+ --placeholder-padding: 4px;
+ }
+
+ .list-item {
+ /* height: var(--list-item-height); */
+ align-items: center;
+ border-bottom: solid var(--divider-color) 1px;
+ display: flex;
+ cursor: pointer;
+ margin-right: -0.2em;
+ background-clip: content-box;
+ }
+
+ .list-item:hover {
+ background-color: var(--secondary-background-color);
+ }
+
+ .list-item:last-of-type {
+ border-bottom: 0;
+ }
+
+ .list-item.playing {
+ background-color: var(--primary-color);
+ }
+
+ .cover {
+ }
+
+ .list-item .cover {
+ height: var(--list-item-height);
+ object-fit: contain;
+ }
+
+ .cover > img {
+ height: 100%;
+ max-width: var(--list-item-height); /* enforce square playlist icons */
+ }
+
+ .cover > svg {
+ height: calc(var(--list-item-height) - var(--placeholder-padding));
+ margin: calc(var(--placeholder-padding) / 2);
+ }
+
+ .list-item > p {
+ margin: 0 0.5em 0 0.5em;
+ }
+ `;
+
+ //Style definition for the Grid view
+ static gridStyles = css`
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(30%, 1fr));
+ grid-gap: 0.5em;
+ }
+
+ .grid-item {
+ position: relative;
+ cursor: pointer;
+ box-shadow: var(--primary-text-color) 0 0 0.2em;
+ }
+
+ .grid-item:hover {
+ box-shadow: rgba(var(--rgb-accent-color), 1) 0 0 0.2em 0.2em;
+ }
+
+ .grid-item-album-image {
+ width: 100%;
+ display: grid;
+ }
+
+ .grid-item-album-image > img {
+ width: 100%;
+ }
+
+ .grid-item-album-image > svg {
+ width: 100%;
+ margin: 10px;
+ }
+
+ .grid-item-album-image.playing {
+ box-shadow: var(--primary-color) 0 0 0.2em 0.2em;
+ }
+ `;
+}
diff --git a/src/types.ts b/src/types.ts
index 273ee6a..42bbddc 100755
--- a/src/types.ts
+++ b/src/types.ts
@@ -26,7 +26,8 @@ export enum ConfigEntry {
Hide_Warning,
Default_Device,
Filter_Devices,
- Hide_Top_Header
+ Hide_Top_Header,
+ Hide_Currently_Playing
}
export interface SpotifyCardConfig extends LovelaceCardConfig {
@@ -60,6 +61,8 @@ export interface SpotifyCardConfig extends LovelaceCardConfig {
filter_devices?: Array;
//hide the top header row and display the spotify icon at the bottom
hide_top_header?: boolean;
+ //hide the currently playing row
+ hide_currently_playing?: boolean;
// locale
}