diff --git a/src/editor-lib.ts b/src/editor-lib.ts deleted file mode 100644 index 40ec81e..0000000 --- a/src/editor-lib.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { SpotifyCardEditor } from './editor'; -import { HomeAssistant, fireEvent } from 'custom-card-helpers'; -import { ChromecastDevice, ConfigEntry, ValueChangedEvent } from './types'; - -export interface ISpotifyCardEditorLib { - hass: HomeAssistant; - accounts: Array; - chromecast_devices: Array; - connectedCallback(): Promise; - getMediaPlayerEntities(): Array; - valueChangedFunction(editor: SpotifyCardEditor, ev: ValueChangedEvent): void; - getValue(value: ConfigEntry): any; -} - -export class SpotifyCardEditorLib { - public hass!: HomeAssistant; - public accounts: Array = []; - public chromecast_devices: Array = []; - - public _parent: SpotifyCardEditor; - - constructor(parent: SpotifyCardEditor) { - this._parent = parent; - } - - public async connectedCallback(): Promise { - this.hass = this.hass || this._parent.hass; - const res: any = await this.hass.callWS({ - type: 'spotcast/accounts', - }); - this.accounts = res; - - const casts: any = await this.hass.callWS({ - type: 'spotcast/castdevices', - }); - - this.chromecast_devices = casts?.map((c: ChromecastDevice) => c.friendly_name); - this._parent.requestUpdate(); - } - - public getMediaPlayerEntities(): Array { - return Object.values(this.hass.states) - .filter((ent) => ent.entity_id.match('media_player[.]')) - .map((e) => e.entity_id); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - public valueChangedFunction(editor: SpotifyCardEditor, ev: ValueChangedEvent): void { - // ev.target.offsetParent checks if editor visible or freetext input is used - if (!editor.config || !editor.hass || ev.target.offsetParent === null) { - return; - } - const { target } = ev; - if (target.value && editor[`_${target.configValue}`] === target.value) { - return; - } - if (target.configValue) { - // Delete item if false or empty - if (target.checked === false || target.value === '') { - const clone = { ...editor.config }; - delete clone[target.configValue]; - editor.config = clone; - } else { - let target_value = target.value; - if (target.configValue == 'height') { - target_value = Number(target_value); - } else if (target.configValue == 'filter_devices') { - target_value = target_value - .split(',') - .map((value: string) => { - return value.trim(); - }) - .filter((value: string) => { - return value != ''; - }); - } - editor.config = { - ...editor.config, - [target.configValue]: target.checked !== undefined ? target.checked : target_value, - }; - } - } - fireEvent(editor, 'config-changed', { config: editor.config }); - editor.requestUpdate(target.configValue); - } - - public getValue(value: ConfigEntry): any { - switch (value) { - case ConfigEntry.Name: - return this._parent.config?.name ?? ''; - case ConfigEntry.Account: - return this._parent.config?.account ?? 'default'; - case ConfigEntry.Spotify_Entity: - // eslint-disable-next-line no-case-declarations - const auto_detected = this.getMediaPlayerEntities().filter((e) => e.includes('spotify')); - return this._parent.config?.spotify_entity ?? (auto_detected.length > 0 ? auto_detected[0] : ''); - case ConfigEntry.Country_Code: - return this._parent.config?.country_code ?? ''; - case ConfigEntry.Limit: - return this._parent.config?.limit ?? 10; - case ConfigEntry.Playlist_Type: - return this._parent.config?.playlist_type ?? 'default'; - case ConfigEntry.Always_Play_Random_Song: - return this._parent.config?.always_play_random_song ?? false; - case ConfigEntry.Height: - return this._parent.config?.height ?? ''; - case ConfigEntry.Display_Style: - return this._parent.config?.display_style ?? 'list'; - case ConfigEntry.Grid_Covers_Per_Row: - return this._parent.config?.grid_covers_per_row ?? 5; - case ConfigEntry.Grid_Center_Covers: - return this._parent.config?.grid_center_covers ?? false; - case ConfigEntry.Hide_Warning: - return this._parent.config?.hide_warning ?? false; - case ConfigEntry.Default_Device: - return this._parent.config?.default_device ?? ''; - case ConfigEntry.Filter_Devices: - return this._parent.config?.filter_devices?.toString() ?? ''; - - default: - break; - } - } -} diff --git a/src/editor.ts b/src/editor.ts index 8772123..9faa8bb 100755 --- a/src/editor.ts +++ b/src/editor.ts @@ -8,13 +8,18 @@ import { CSSResult, css, } from 'lit-element'; -import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; +import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; -import { SpotifyCardConfig, ConfigEntry, DisplayStyle, PlaylistType } from './types'; +import { + SpotifyCardConfig, + ConfigEntry, + DisplayStyle, + PlaylistType, + ChromecastDevice, + ValueChangedEvent, +} from './types'; import { localize } from './localize/localize'; -import { SpotifyCardEditorLib, ISpotifyCardEditorLib } from './editor-lib'; - export const PLAYLIST_TYPES = ['default', 'featured', 'discover-weekly']; //define tabs of editor @@ -43,32 +48,130 @@ const options = { export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor { @property({ type: Object }) public hass!: HomeAssistant; - @internalProperty() public config?: SpotifyCardConfig; + @internalProperty() private config?: SpotifyCardConfig; - @internalProperty() public _toggle?: boolean; - - @internalProperty() - private lib: ISpotifyCardEditorLib; + @internalProperty() private _toggle?: boolean; accounts: Array = []; - chromecast_devices: Array = []; - constructor() { - super(); - this.lib = new SpotifyCardEditorLib(this); - } + @internalProperty() + chromecast_devices: Array = []; async connectedCallback(): Promise { super.connectedCallback(); - await this.lib.connectedCallback(); + const res: any = await this.hass.callWS({ + type: 'spotcast/accounts', + }); + this.accounts = res; + + const casts: any = await this.hass.callWS({ + type: 'spotcast/castdevices', + }); + + this.chromecast_devices = casts?.map((c: ChromecastDevice) => c.friendly_name); } public setConfig(_config: SpotifyCardConfig): void { this.config = _config; } + public getMediaPlayerEntities(): Array { + return Object.values(this.hass.states) + .filter((ent) => ent.entity_id.match('media_player[.]')) + .map((e) => e.entity_id); + } + + private _toggleOption(ev): void { + this._toggleThing(ev, options); + } + + private _toggleThing(ev, optionList): void { + const show = !optionList[ev.target.option].show; + for (const [key] of Object.entries(optionList)) { + optionList[key].show = false; + } + optionList[ev.target.option].show = show; + this._toggle = !this._toggle; + } + + public valueChanged(ev: ValueChangedEvent): void { + // ev.target.offsetParent checks if this visible or freetext input is used + if (!this.config || !this.hass || ev.target.offsetParent === null) { + return; + } + const { target } = ev; + if (target.value && this[`_${target.configValue}`] === target.value) { + return; + } + if (target.configValue) { + // Delete item if false or empty + if (target.checked === false || target.value === '') { + const clone = { ...this.config }; + delete clone[target.configValue]; + this.config = clone; + } else { + let target_value = target.value; + if (target.configValue == 'height') { + target_value = Number(target_value); + } else if (target.configValue == 'filter_devices') { + target_value = target_value + .split(',') + .map((value: string) => { + return value.trim(); + }) + .filter((value: string) => { + return value != ''; + }); + } + this.config = { + ...this.config, + [target.configValue]: target.checked !== undefined ? target.checked : target_value, + }; + } + } + fireEvent(this, 'config-changed', { config: this.config }); + } + + public getValue(value: ConfigEntry): any { + switch (value) { + case ConfigEntry.Name: + return this.config?.name ?? ''; + case ConfigEntry.Account: + return this.config?.account ?? 'default'; + case ConfigEntry.Spotify_Entity: + // eslint-disable-next-line no-case-declarations + const auto_detected = this.getMediaPlayerEntities().filter((e) => e.includes('spotify')); + return this.config?.spotify_entity ?? (auto_detected.length > 0 ? auto_detected[0] : ''); + case ConfigEntry.Country_Code: + return this.config?.country_code ?? ''; + case ConfigEntry.Limit: + return this.config?.limit ?? 10; + case ConfigEntry.Playlist_Type: + return this.config?.playlist_type ?? 'default'; + case ConfigEntry.Always_Play_Random_Song: + return this.config?.always_play_random_song ?? false; + case ConfigEntry.Height: + return this.config?.height ?? ''; + case ConfigEntry.Display_Style: + return this.config?.display_style ?? 'list'; + case ConfigEntry.Grid_Covers_Per_Row: + return this.config?.grid_covers_per_row ?? 5; + case ConfigEntry.Grid_Center_Covers: + return this.config?.grid_center_covers ?? false; + case ConfigEntry.Hide_Warning: + return this.config?.hide_warning ?? false; + case ConfigEntry.Default_Device: + return this.config?.default_device ?? ''; + case ConfigEntry.Filter_Devices: + return this.config?.filter_devices?.toString() ?? ''; + + default: + break; + } + } + private renderGeneral(): TemplateResult { - const media_player_entities = this.lib.getMediaPlayerEntities(); + const media_player_entities = this.getMediaPlayerEntities(); return html`
@@ -80,7 +183,7 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor > ${this.accounts.map((item) => html` ${item} `)} @@ -95,7 +198,7 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor > ${media_player_entities.map((item) => html` ${item} `)} @@ -111,7 +214,7 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor ).indexOf( - this.lib.getValue(ConfigEntry.Playlist_Type) + this.getValue(ConfigEntry.Playlist_Type) )} > ${(Object.values(PlaylistType) as Array).map((item) => html` ${item} `)} @@ -121,7 +224,7 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor
${localize('settings.limit')}
@@ -140,14 +243,14 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor
@@ -171,7 +274,7 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor
@@ -196,7 +299,7 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor ).indexOf( - this.lib.getValue(ConfigEntry.Display_Style) + this.getValue(ConfigEntry.Display_Style) )} > ${(Object.values(DisplayStyle) as Array).map((item) => html` ${item} `)} @@ -206,7 +309,7 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor
${localize('settings.grid_covers_per_row')}
@@ -278,23 +381,6 @@ export class SpotifyCardEditor extends LitElement implements LovelaceCardEditor `; } - private _toggleOption(ev): void { - this._toggleThing(ev, options); - } - - private _toggleThing(ev, optionList): void { - const show = !optionList[ev.target.option].show; - for (const [key] of Object.entries(optionList)) { - optionList[key].show = false; - } - optionList[ev.target.option].show = show; - this._toggle = !this._toggle; - } - - public valueChanged(ev: CustomEvent): void { - this.lib.valueChangedFunction(this, ev); - } - static get styles(): CSSResult { return css` .option { diff --git a/src/spotcast-connector.ts b/src/spotcast-connector.ts index 19a23ec..f8f62fc 100644 --- a/src/spotcast-connector.ts +++ b/src/spotcast-connector.ts @@ -1,5 +1,5 @@ import { ConnectDevice, CurrentPlayer, Playlist, ChromecastDevice, PlaybackOptions } from './types'; -import { ISpotifyCardLib } from './spotify-card-lib'; +import { SpotifyCard } from './spotify-card'; interface Message { type: string; account?: string; @@ -13,11 +13,7 @@ interface PlaylistMessage extends Message { } export interface ISpotcastConnector { - parent: ISpotifyCardLib; - playlists: Array; - devices: Array; - player?: CurrentPlayer; - chromecast_devices: Array; + parent: SpotifyCard; is_loading(): boolean; is_loaded(): boolean; playUri(uri: string): void; @@ -31,18 +27,14 @@ export interface ISpotcastConnector { } export class SpotcastConnector implements ISpotcastConnector { - public parent: ISpotifyCardLib; - public playlists: Array = []; - public devices: Array = []; - public player?: CurrentPlayer; - public chromecast_devices: Array = []; + public parent: SpotifyCard; // data is valid for 4 secs otherwise the service is spammed bcos of the entitiy changes - state_ttl = 4000; - last_state_update_time = 0; + private state_ttl = 4000; + private last_state_update_time = 0; - loading = false; + private loading = false; - constructor(parent: ISpotifyCardLib) { + constructor(parent: SpotifyCard) { this.parent = parent; } @@ -56,7 +48,7 @@ export class SpotcastConnector implements ISpotcastConnector { } public is_loaded(): boolean { - if (this.playlists.length !== undefined) { + if (this.parent.playlists.length !== undefined) { return true; } return false; @@ -77,11 +69,13 @@ export class SpotcastConnector implements ISpotcastConnector { if (!current_player) { const default_device = this.parent.config.default_device; if (default_device) { - const connect_device = this.devices.filter((device) => device.name == default_device); + const connect_device = this.parent.devices.filter((device) => device.name == default_device); if (connect_device.length > 0) { return this.playUriOnConnectDevice(connect_device[0].id, uri); } else { - const cast_device = this.chromecast_devices.filter((cast) => cast.friendly_name == default_device); + const cast_device = this.parent.chromecast_devices.filter( + (cast) => cast.friendly_name == default_device + ); if (cast_device.length > 0) { return this.playUriOnCastDevice(cast_device[0].friendly_name, uri); } @@ -127,17 +121,20 @@ export class SpotcastConnector implements ISpotcastConnector { } // console.log('cache is NOT valid:', this.last_state_update_time); try { + this.loading = true; await this.fetchDevices(); await this.fetchPlayer(); await this.fetchChromecasts(); this.last_state_update_time = new Date().getTime(); } catch (e) { throw Error('updateState error: ' + e); + } finally { + this.loading = false; } } public getCurrentPlayer(): ConnectDevice | undefined { - return this.player?.device; + return this.parent.player?.device; } public async fetchPlayer(): Promise { @@ -147,14 +144,14 @@ export class SpotcastConnector implements ISpotcastConnector { account: this.parent.config.account, }; try { - this.player = await this.parent.hass.callWS(message); + this.parent.player = await this.parent.hass.callWS(message); } catch (e) { throw Error('Failed to fetch player: ' + e); } // console.log('fetchPlayer:', JSON.stringify(this.player, null, 2)); } - public async fetchDevices(): Promise { + private async fetchDevices(): Promise { // console.log('fetchDevices'); const message: Message = { type: 'spotcast/devices', @@ -162,7 +159,7 @@ export class SpotcastConnector implements ISpotcastConnector { }; try { const res: any = >await this.parent.hass.callWS(message); - this.devices = res.devices; + this.parent.devices = res.devices; } catch (e) { throw Error('Failed to fetch devices: ' + e); } @@ -172,11 +169,11 @@ export class SpotcastConnector implements ISpotcastConnector { /** * Use HA state for now */ - public async fetchChromecasts(): Promise { + private async fetchChromecasts(): Promise { try { - this.chromecast_devices = await this.parent.hass.callWS({ type: 'spotcast/castdevices' }); + this.parent.chromecast_devices = await this.parent.hass.callWS({ type: 'spotcast/castdevices' }); } catch (e) { - this.chromecast_devices = []; + this.parent.chromecast_devices = []; throw Error('Failed to fetch devices: ' + e); } // console.log('fetchChromecasts2:', this.chromecast_devices); @@ -196,9 +193,11 @@ export class SpotcastConnector implements ISpotcastConnector { // message.locale = 'implement me later' try { const res: any = >await this.parent.hass.callWS(message); - this.playlists = res.items; + this.parent.playlists = res.items; } catch (e) { throw Error('Failed to fetch playlists: ' + e); + } finally { + this.loading = false; } // console.log('PLAYLISTS:', JSON.stringify(this.playlists, null, 2)); } diff --git a/src/spotify-card-lib.ts b/src/spotify-card-lib.ts deleted file mode 100644 index d91bba6..0000000 --- a/src/spotify-card-lib.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { - ConnectDevice, - Playlist, - ChromecastDevice, - isConnectDevice, - DisplayStyle, - SpotifyCardConfig, - PlaylistType, -} from './types'; - -import { HomeAssistant } from 'custom-card-helpers'; -import { - servicesColl, - subscribeEntities, - HassEntities, - HassEntity, - Collection, - HassServices, -} from 'home-assistant-js-websocket'; -import { SpotcastConnector, ISpotcastConnector } from './spotcast-connector'; -import { SpotifyCard } from './spotify-card'; -import { PLAYLIST_TYPES } from './editor'; - -export interface ISpotifyCardLib { - hass: HomeAssistant; - config: SpotifyCardConfig; - spotify_state?: HassEntity; - setConfig(config: SpotifyCardConfig): string; - getDisplayStyle(): DisplayStyle; - getPlayingState(): boolean; - getShuffleState(): boolean; - getSpotifyEntityState(): string; - isSpotcastInstalled(): boolean; - isSpotifyInstalled(): boolean; - requestUpdate(): void; - getCurrentPlayer(): ConnectDevice | undefined; - dataAvailable(): boolean; - updated(hass: HomeAssistant): void; - connectedCallback(): void; - disconnectedCallback(): void; - doSubscribeEntities(): void; - getDefaultDevice(): string | undefined; - getFilteredDevices(): [ConnectDevice[], ChromecastDevice[]]; - getPlaylists(): Playlist[]; - isThisPlaylistPlaying(item: Playlist): boolean; - playUri(elem: MouseEvent, uri: string): void; - onShuffleSelect(): void; - handlePlayPauseEvent(ev: Event, command: string): void; - spotifyDeviceSelected(device: ConnectDevice): void; - chromecastDeviceSelected(device: ChromecastDevice): void; -} - -export class SpotifyCardLib implements ISpotifyCardLib { - public hass!: HomeAssistant; - public config!: SpotifyCardConfig; - public spotify_state?: HassEntity; - - // These are 'private' - public _parent: SpotifyCard; - public _spotcast_connector!: ISpotcastConnector; - public _unsubscribe_entitites?: any; - public _spotify_installed = false; - public _fetch_time_out: any = 0; - - constructor(parent: SpotifyCard) { - this._parent = parent; - this.hass = parent.hass; - } - - public setConfig(config: SpotifyCardConfig): string { - this.config = config; - // 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; - if ( - this.config.playlist_type && - !(Object.values(PlaylistType) as Array).includes(this.config.playlist_type.toLowerCase()) - ) { - return 'playlist_type'; - } - if ( - this.config.display_style && - !(Object.values(DisplayStyle) as Array).includes(this.config.display_style.toLowerCase()) - ) { - return 'display_style'; - } - return ''; - } - - public getDisplayStyle(): DisplayStyle { - // Display spotify playlists - if (this.config.display_style?.toLowerCase() == 'grid') { - return DisplayStyle.Grid; - } else { - return DisplayStyle.List; - } - } - - public getPlayingState(): boolean { - return this.spotify_state?.state == 'playing' ?? false; - } - - public getShuffleState(): boolean { - return this._spotcast_connector.player?.shuffle_state ?? false; - } - - public getSpotifyEntityState(): string { - return this.spotify_state ? this.spotify_state.state : ''; - } - - public isSpotcastInstalled(): boolean { - if (this.hass?.connection && this.getHassConnection().state.spotcast !== undefined) { - return true; - } - return false; - } - - public getHassConnection(): Collection { - return servicesColl(this.hass.connection); - } - - public isSpotifyInstalled(): boolean { - return this._spotify_installed; - } - - public async requestUpdate(): Promise { - if (this.isSpotcastInstalled() && !this._spotcast_connector.is_loading()) { - await this._spotcast_connector.updateState().then(async () => { - await this._spotcast_connector.fetchPlaylists().then(async () => { - await this._parent.requestUpdate(); - }); - }); - } - } - - public getCurrentPlayer(): ConnectDevice | undefined { - return this._spotcast_connector.getCurrentPlayer(); - } - - public dataAvailable(): boolean { - return this._spotcast_connector.is_loaded(); - } - - public updated(hass: HomeAssistant): void { - this.hass = hass; - this.doSubscribeEntities(); - } - - public connectedCallback(): void { - this._spotcast_connector = new SpotcastConnector(this); - //get all available entities and when they update - this.doSubscribeEntities(); - if (this.hass) { - //request update of spotcast data - this.requestUpdate(); - } - } - - public disconnectedCallback(): void { - if (this._unsubscribe_entitites) { - this._unsubscribe_entitites(); - this._unsubscribe_entitites = undefined; - } - } - - public doSubscribeEntities(): void { - if (this.hass?.connection && !this._unsubscribe_entitites && this._parent.isHASSConnected()) { - this._unsubscribe_entitites = subscribeEntities(this.hass.connection, (entities) => - this.entitiesUpdated(entities) - ); - } - } - - //Callback when hass-entity has changed - public 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) { - // Debounce updates to 500ms - if (this._fetch_time_out) { - clearTimeout(this._fetch_time_out); - } - this._fetch_time_out = setTimeout(() => { - this.requestUpdate(); - }, 500); - } - } - - public 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; - } - - public 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; - } - - public getFilteredDevices(): [ConnectDevice[], ChromecastDevice[]] { - const spotify_connect_devices = this._spotcast_connector.devices.filter(this.checkIfAllowedToShow, this); - const chromecast_devices = this._spotcast_connector.chromecast_devices.filter(this.checkIfAllowedToShow, this); - return [spotify_connect_devices, chromecast_devices]; - } - - public getPlaylists(): Playlist[] { - return this._spotcast_connector.playlists; - } - - public isThisPlaylistPlaying(item: Playlist): boolean { - return this.spotify_state?.attributes.media_playlist === item.name; - } - - public playUri(elem: MouseEvent, uri: string): void { - const loading = 'loading'; - const srcElement = elem.srcElement as any; - if (srcElement?.localName == 'div') srcElement.children[1].classList.add(loading); - else if (srcElement?.localName == 'svg') srcElement.parentElement.classList.add(loading); - else if (srcElement?.localName == 'path') srcElement.parentElement.parentElement.classList.add(loading); - else if (srcElement?.localName == 'img') srcElement.nextElementSibling.classList.add(loading); - else if (srcElement?.localName == 'p') srcElement.parentElement.children[1].classList.add(loading); - else console.log(srcElement); - this._spotcast_connector.playUri(uri); - } - - public onShuffleSelect(): void { - if (this.spotify_state?.state == 'playing') { - this.hass.callService('media_player', 'shuffle_set', { - entity_id: this.spotify_state.entity_id, - shuffle: !this._spotcast_connector.player?.shuffle_state, - }); - } - } - - public handlePlayPauseEvent(ev: Event, command: string): void { - ev.stopPropagation(); - if (this.spotify_state) { - this.hass.callService('media_player', command, { entity_id: this.spotify_state.entity_id }); - } - } - - public spotifyDeviceSelected(device: ConnectDevice): void { - const current_player = this._spotcast_connector.getCurrentPlayer(); - if (current_player) { - return this._spotcast_connector.transferPlaybackToConnectDevice(device.id); - } - const playlist = this._spotcast_connector.playlists[0]; - console.log('spotifyDeviceSelected playing first playlist'); - this._spotcast_connector.playUriOnConnectDevice(device.id, playlist.uri); - } - - public chromecastDeviceSelected(device: ChromecastDevice): void { - const current_player = this._spotcast_connector.getCurrentPlayer(); - if (current_player) { - return this._spotcast_connector.transferPlaybackToCastDevice(device.friendly_name); - } - - const playlist = this._spotcast_connector.playlists[0]; - console.log('chromecastDeviceSelected playing first playlist'); - this._spotcast_connector.playUriOnCastDevice(device.friendly_name, playlist.uri); - } -} diff --git a/src/spotify-card.ts b/src/spotify-card.ts index 2356ab0..cb38d68 100755 --- a/src/spotify-card.ts +++ b/src/spotify-card.ts @@ -14,10 +14,23 @@ import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; import { CARD_VERSION } from './const'; -import { SpotifyCardConfig, DisplayStyle } from './types'; +import { + SpotifyCardConfig, + DisplayStyle, + PlaylistType, + isConnectDevice, + ConnectDevice, + ChromecastDevice, + Playlist, + CurrentPlayer, + isCurrentPlayer, +} from './types'; + +import { PLAYLIST_TYPES } from './editor'; import { localize } from './localize/localize'; -import { SpotifyCardLib, ISpotifyCardLib } from './spotify-card-lib'; +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 */ @@ -36,49 +49,166 @@ console.info( preview: true, }); -@customElement('spotify-card') -export class SpotifyCard extends LitElement { - // Calls the editor - public static async getConfigElement(): Promise { - return document.createElement('spotify-card-editor') as LovelaceCardEditor; +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; +} - // Returns default config for Lovelace picker - public static getStubConfig(): Record { - return {}; +export function hasChangedMediaPlayer(newVal: HassEntity, oldVal: HassEntity): boolean { + if (!oldVal) { + return true; + } + if (newVal.state != oldVal.state || newVal.attributes.shuffle != oldVal.attributes.shuffle) { + return true; } + return false; +} - @property({ type: Object }) +@customElement('spotify-card') +export class SpotifyCard extends LitElement { public hass!: HomeAssistant; @property({ type: Object }) public config!: SpotifyCardConfig; - @internalProperty() - private lib: ISpotifyCardLib; + @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.lib = new SpotifyCardLib(this); + 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 - const var_error = this.lib.setConfig(_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 { + async connectedCallback(): Promise { super.connectedCallback(); - this.lib.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; + } + } + + //Only for logging purposes. Remove for release + protected shouldUpdate(changedProperties): any { + changedProperties.forEach((oldValue, propName) => { + console.log(`${propName} changed. oldValue: ${oldValue}`); + console.log(oldValue); + }); + return true; } protected updated(changedProps: PropertyValues): void { super.updated(changedProps); - this.lib.updated(this.hass); this.updateComplete.then(() => { for (const cover of this.renderRoot.querySelectorAll('[data-spotify-image-url]') as NodeListOf) { const downloadingImage = new Image(); @@ -90,33 +220,143 @@ export class SpotifyCard extends LitElement { }); } - public disconnectedCallback(): void { - super.disconnectedCallback(); - this.lib.disconnectedCallback(); + 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; } - //Helper function for testing - public isHASSConnected(): boolean { - return this.isConnected; + 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 srcElement = elem.srcElement as any; + if (srcElement?.localName == 'div') srcElement.children[1].classList.add(loading); + else if (srcElement?.localName == 'svg') srcElement.parentElement.classList.add(loading); + else if (srcElement?.localName == 'path') srcElement.parentElement.parentElement.classList.add(loading); + else if (srcElement?.localName == 'img') srcElement.nextElementSibling.classList.add(loading); + else if (srcElement?.localName == 'p') srcElement.parentElement.children[1].classList.add(loading); + else console.log(srcElement); + this.spotcast_connector.playUri(uri); + } + + private onShuffleSelect(): void { + if (this._spotify_state?.state == 'playing') { + this.hass.callService('media_player', 'shuffle_set', { + entity_id: this._spotify_state.entity_id, + shuffle: !this.player?.shuffle_state, + }); + } + } + + private handlePlayPauseEvent(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 spotifyDeviceSelected(device: ConnectDevice): void { + 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(device: ChromecastDevice): void { + 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.lib.isSpotcastInstalled()) { + if (!this.isSpotcastInstalled()) { warning = this.showWarning(localize('common.show_missing_spotcast')); } - if (!this.lib.isSpotifyInstalled()) { + 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.lib.dataAvailable()) { - this.lib.requestUpdate(); - } else { - switch (this.lib.getDisplayStyle()) { + if (this.spotcast_connector.is_loaded()) { + switch (this.getDisplayStyle()) { case DisplayStyle.Grid: { content = this.generateGridView(); break; @@ -129,8 +369,8 @@ export class SpotifyCard extends LitElement { } return html` - ${this.lib.config.hide_warning ? '' : warning} + ${this.config.hide_warning ? '' : warning}
@@ -162,9 +402,7 @@ export class SpotifyCard extends LitElement { stroke="null" /> - ${this.lib.getCurrentPlayer()?.name ?? - this.lib.getDefaultDevice() ?? - localize('common.choose_player')} + ${this.getCurrentPlayer()?.name ?? this.getDefaultDevice() ?? localize('common.choose_player')}
@@ -173,11 +411,8 @@ export class SpotifyCard extends LitElement {