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`

+ ${this._spotify_state?.attributes.media_title} - ${this._spotify_state?.attributes.media_artist} +

` + : 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 }