diff --git a/README.md b/README.md index 608f8f7..8aca8b5 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,16 @@ Web component card which can be used as a Lovelace [Home Assistants](https://www Forum thread: https://community.home-assistant.io/t/spotify-lovelace-card/103525 -This card supports listing the users currently available devices and the users 10 top playlists on [Spotify](https://www.spotify.com). +This card supports listing the users currently available devices and the users top playlists on [Spotify](https://www.spotify.com). Choose an online media player and click on a playlist to play it on the device. This component will query the current playback from the Spotify Web API and tries to reflect the current status wrt to device and playlist if something is playing. The component uses the [Spotify Web API](https://developer.spotify.com/documentation/web-api/). +***New from version 1.5*** +The card can make use of [My Spotify Chromecast custom component](https://github.com/fondberg/spotcast) if it is installed, to initiate playback on idle chromecast devices. Please read that README for any limitations. +This release also adds a limit configuration property to make the number of playlists retrieved configurable. + ![Screenshot](/spotify-card-highlight.png) ### Requirements @@ -31,7 +35,7 @@ Add the resource in lovelace config: ``` - type: module url: >- - https://cdn.jsdelivr.net/gh/custom-cards/spotify-card@1.4/dist/spotify-card.umd.js + https://cdn.jsdelivr.net/gh/custom-cards/spotify-card@1.5/dist/spotify-card.umd.js ``` ##### master version: @@ -48,6 +52,7 @@ Now add the card like this: cards: - type: 'custom:spotify-card' client_id: + limit: ``` ### Improvements to come thru PR or with patience diff --git a/dist/spotify-card.cjs.js b/dist/spotify-card.cjs.js index 127b16a..82ead87 100644 --- a/dist/spotify-card.cjs.js +++ b/dist/spotify-card.cjs.js @@ -28,7 +28,8 @@ class PlayerSelect extends preact.Component { constructor() { super(); this.state = { - selectedDevice: '-- choose mediaplayer --' + selectedDevice: '-- choose mediaplayer --', + chromecastDevices: [] }; } @@ -38,6 +39,17 @@ class PlayerSelect extends preact.Component { selectedDevice: props.selectedDevice.name }); } + + if (props.hass) { + const chromecastSensor = props.hass.states['sensor.chromecast_devices']; + + if (chromecastSensor) { + const chromecastDevices = JSON.parse(chromecastSensor.attributes.devices_json); + this.setState({ + chromecastDevices + }); + } + } } selectDevice(device) { @@ -47,11 +59,17 @@ class PlayerSelect extends preact.Component { this.props.onMediaplayerSelect(device); } + selectChromecastDevice(device) { + this.props.onChromecastDeviceSelect(device); + } + render() { const { devices - } = this.props; // console.log('PlayerSelect: devices', devices); - + } = this.props; + const { + chromecastDevices + } = this.state; return html` @@ -84,6 +107,7 @@ class PlayerSelect extends preact.Component { class SpotifyCard extends preact.Component { constructor(props) { super(props); + this.dataRefreshToken = null; this.state = { user: {}, playlists: [], @@ -93,7 +117,7 @@ class SpotifyCard extends preact.Component { playingPlaylist: null, authenticationRequired: true }; - this.scopes = ['user-read-private', 'user-read-email', 'playlist-read-private', 'user-read-birthdate', 'user-read-playback-state', 'user-modify-playback-state']; + this.scopes = ['playlist-read-private', 'user-read-playback-state', 'user-modify-playback-state']; } async componentDidMount() { @@ -111,7 +135,6 @@ class SpotifyCard extends preact.Component { if (userResp.error.status === 401) { // Have a token but it is old if (access_token && 0 + token_expires_ms - new Date().getTime() < 0) { - // console.log('Will do auth, has token but ut us old'); return this.authenticateSpotify(); } // no token - show login button @@ -128,10 +151,6 @@ class SpotifyCard extends preact.Component { }); } - this.setState({ - authenticationRequired: false - }); - if (hashParams.get('access_token')) { const expires_in = hashParams.get('expires_in'); localStorage.setItem('access_token', access_token); @@ -143,14 +162,30 @@ class SpotifyCard extends preact.Component { }, '', newurl); } - const playlists = await fetch('https://api.spotify.com/v1/me/playlists?limit=10', { + this.setState({ + user: userResp, + authenticationRequired: false + }); + await this.refreshPlayData(); + this.dataRefreshToken = setInterval(async () => { + await this.refreshPlayData(); + }, 5000); + } + + componentWillUnmount() { + clearInterval(this.dataRefreshToken); + } + + async refreshPlayData() { + const headers = { + Authorization: `Bearer ${localStorage.getItem('access_token')}` + }; + const playlists = await fetch('https://api.spotify.com/v1/me/playlists?limit=' + this.props.limit, { headers }).then(r => r.json()).then(p => p.items); const devices = await fetch('https://api.spotify.com/v1/me/player/devices', { headers - }).then(r => r.json()).then(r => r.devices); // console.log('Response: playlists', playlists); - // console.log('Response: devices', devices); - + }).then(r => r.json()).then(r => r.devices); const currentPlayerRes = await fetch('https://api.spotify.com/v1/me/player', { headers }); @@ -164,6 +199,7 @@ class SpotifyCard extends preact.Component { selectedDevice = currentPlayer.device; if (currentPlayer.context && currentPlayer.context.external_urls) { + // console.log('Currently playing:', currentPlayer); const currPlayingHref = currentPlayer.context.external_urls.spotify; playingPlaylist = playlists.find(pl => currPlayingHref === pl.external_urls.spotify); } @@ -171,7 +207,6 @@ class SpotifyCard extends preact.Component { } this.setState({ - user: userResp, playlists, devices, selectedDevice, @@ -194,6 +229,7 @@ class SpotifyCard extends preact.Component { } = this.state; if (!selectedPlaylist || !selectedDevice) { + console.error('Will not play because there is no playlist or device selected'); return; } @@ -218,6 +254,27 @@ class SpotifyCard extends preact.Component { this.playPlaylist(); } + onMediaPlayerSelect(device) { + this.setState({ + selectedDevice: device + }); + } + + onChromecastDeviceSelect(device) { + const playlist = this.state.playingPlaylist ? this.state.playingPlaylist : this.state.playlists[0]; + + if (!playlist) { + console.error('Nothing to play, skipping starting chromecast device'); + return; + } // console.log('Starting:', playlist.uri, ' on ', device.name); + + + this.props.hass.callService('spotcast', 'start', { + device_name: device.name, + uri: playlist.uri + }); + } + getHighlighted(playlist) { const { selectedPlaylist @@ -237,11 +294,10 @@ class SpotifyCard extends preact.Component { render() { const { authenticationRequired, - user, playlists, devices, selectedDevice - } = this.state; // console.log('SpotifyCard: playlists.length:', playlists.length, ' authenticationRequired:', authenticationRequired, ' devices.length', devices.length); + } = this.state; if (authenticationRequired) { return html` @@ -259,10 +315,7 @@ class SpotifyCard extends preact.Component { <${Header} />
${playlists.map((playlist, idx) => { - const image = playlist.images[0] ? playlist.images[0].url : 'https://via.placeholder.com/150x150.png?text=No+image'; // if(!playlist.images[0]) { - // console.log('no image, click to expand the object to the right:', playlist.images); - // } - + const image = playlist.images[0] ? playlist.images[0].url : 'https://via.placeholder.com/150x150.png?text=No+image'; return html`
this.setState({ - selectedDevice: device - })} + hass=${this.props.hass} + onMediaplayerSelect=${device => this.onMediaPlayerSelect(device)} + onChromecastDeviceSelect=${device => this.onChromecastDeviceSelect(device)} />
@@ -454,6 +507,7 @@ class SpotifyCardWebComponent extends HTMLElement { } set hass(hass) { + // console.log('HASS:', hass); if (!this.savedHass) { this.savedHass = hass; } @@ -484,7 +538,7 @@ class SpotifyCardWebComponent extends HTMLElement { this.shadow.appendChild(styleElement); this.shadow.appendChild(mountPoint); preact.render(html` - <${SpotifyCard} clientId=${this.config.client_id} /> + <${SpotifyCard} clientId=${this.config.client_id} limit=${this.config.limit || 10} hass=${this.savedHass}/> `, mountPoint); } diff --git a/dist/spotify-card.esm.js b/dist/spotify-card.esm.js index d439a3f..e934e00 100644 --- a/dist/spotify-card.esm.js +++ b/dist/spotify-card.esm.js @@ -24,7 +24,8 @@ class PlayerSelect extends Component { constructor() { super(); this.state = { - selectedDevice: '-- choose mediaplayer --' + selectedDevice: '-- choose mediaplayer --', + chromecastDevices: [] }; } @@ -34,6 +35,17 @@ class PlayerSelect extends Component { selectedDevice: props.selectedDevice.name }); } + + if (props.hass) { + const chromecastSensor = props.hass.states['sensor.chromecast_devices']; + + if (chromecastSensor) { + const chromecastDevices = JSON.parse(chromecastSensor.attributes.devices_json); + this.setState({ + chromecastDevices + }); + } + } } selectDevice(device) { @@ -43,11 +55,17 @@ class PlayerSelect extends Component { this.props.onMediaplayerSelect(device); } + selectChromecastDevice(device) { + this.props.onChromecastDeviceSelect(device); + } + render() { const { devices - } = this.props; // console.log('PlayerSelect: devices', devices); - + } = this.props; + const { + chromecastDevices + } = this.state; return html` @@ -80,6 +103,7 @@ class PlayerSelect extends Component { class SpotifyCard extends Component { constructor(props) { super(props); + this.dataRefreshToken = null; this.state = { user: {}, playlists: [], @@ -89,7 +113,7 @@ class SpotifyCard extends Component { playingPlaylist: null, authenticationRequired: true }; - this.scopes = ['user-read-private', 'user-read-email', 'playlist-read-private', 'user-read-birthdate', 'user-read-playback-state', 'user-modify-playback-state']; + this.scopes = ['playlist-read-private', 'user-read-playback-state', 'user-modify-playback-state']; } async componentDidMount() { @@ -107,7 +131,6 @@ class SpotifyCard extends Component { if (userResp.error.status === 401) { // Have a token but it is old if (access_token && 0 + token_expires_ms - new Date().getTime() < 0) { - // console.log('Will do auth, has token but ut us old'); return this.authenticateSpotify(); } // no token - show login button @@ -124,10 +147,6 @@ class SpotifyCard extends Component { }); } - this.setState({ - authenticationRequired: false - }); - if (hashParams.get('access_token')) { const expires_in = hashParams.get('expires_in'); localStorage.setItem('access_token', access_token); @@ -139,14 +158,30 @@ class SpotifyCard extends Component { }, '', newurl); } - const playlists = await fetch('https://api.spotify.com/v1/me/playlists?limit=10', { + this.setState({ + user: userResp, + authenticationRequired: false + }); + await this.refreshPlayData(); + this.dataRefreshToken = setInterval(async () => { + await this.refreshPlayData(); + }, 5000); + } + + componentWillUnmount() { + clearInterval(this.dataRefreshToken); + } + + async refreshPlayData() { + const headers = { + Authorization: `Bearer ${localStorage.getItem('access_token')}` + }; + const playlists = await fetch('https://api.spotify.com/v1/me/playlists?limit=' + this.props.limit, { headers }).then(r => r.json()).then(p => p.items); const devices = await fetch('https://api.spotify.com/v1/me/player/devices', { headers - }).then(r => r.json()).then(r => r.devices); // console.log('Response: playlists', playlists); - // console.log('Response: devices', devices); - + }).then(r => r.json()).then(r => r.devices); const currentPlayerRes = await fetch('https://api.spotify.com/v1/me/player', { headers }); @@ -160,6 +195,7 @@ class SpotifyCard extends Component { selectedDevice = currentPlayer.device; if (currentPlayer.context && currentPlayer.context.external_urls) { + // console.log('Currently playing:', currentPlayer); const currPlayingHref = currentPlayer.context.external_urls.spotify; playingPlaylist = playlists.find(pl => currPlayingHref === pl.external_urls.spotify); } @@ -167,7 +203,6 @@ class SpotifyCard extends Component { } this.setState({ - user: userResp, playlists, devices, selectedDevice, @@ -190,6 +225,7 @@ class SpotifyCard extends Component { } = this.state; if (!selectedPlaylist || !selectedDevice) { + console.error('Will not play because there is no playlist or device selected'); return; } @@ -214,6 +250,27 @@ class SpotifyCard extends Component { this.playPlaylist(); } + onMediaPlayerSelect(device) { + this.setState({ + selectedDevice: device + }); + } + + onChromecastDeviceSelect(device) { + const playlist = this.state.playingPlaylist ? this.state.playingPlaylist : this.state.playlists[0]; + + if (!playlist) { + console.error('Nothing to play, skipping starting chromecast device'); + return; + } // console.log('Starting:', playlist.uri, ' on ', device.name); + + + this.props.hass.callService('spotcast', 'start', { + device_name: device.name, + uri: playlist.uri + }); + } + getHighlighted(playlist) { const { selectedPlaylist @@ -233,11 +290,10 @@ class SpotifyCard extends Component { render() { const { authenticationRequired, - user, playlists, devices, selectedDevice - } = this.state; // console.log('SpotifyCard: playlists.length:', playlists.length, ' authenticationRequired:', authenticationRequired, ' devices.length', devices.length); + } = this.state; if (authenticationRequired) { return html` @@ -255,10 +311,7 @@ class SpotifyCard extends Component { <${Header} />
${playlists.map((playlist, idx) => { - const image = playlist.images[0] ? playlist.images[0].url : 'https://via.placeholder.com/150x150.png?text=No+image'; // if(!playlist.images[0]) { - // console.log('no image, click to expand the object to the right:', playlist.images); - // } - + const image = playlist.images[0] ? playlist.images[0].url : 'https://via.placeholder.com/150x150.png?text=No+image'; return html`
this.setState({ - selectedDevice: device - })} + hass=${this.props.hass} + onMediaplayerSelect=${device => this.onMediaPlayerSelect(device)} + onChromecastDeviceSelect=${device => this.onChromecastDeviceSelect(device)} />
@@ -450,6 +503,7 @@ class SpotifyCardWebComponent extends HTMLElement { } set hass(hass) { + // console.log('HASS:', hass); if (!this.savedHass) { this.savedHass = hass; } @@ -480,7 +534,7 @@ class SpotifyCardWebComponent extends HTMLElement { this.shadow.appendChild(styleElement); this.shadow.appendChild(mountPoint); render(html` - <${SpotifyCard} clientId=${this.config.client_id} /> + <${SpotifyCard} clientId=${this.config.client_id} limit=${this.config.limit || 10} hass=${this.savedHass}/> `, mountPoint); } diff --git a/dist/spotify-card.umd.js b/dist/spotify-card.umd.js index 608483c..7e33b8f 100644 --- a/dist/spotify-card.umd.js +++ b/dist/spotify-card.umd.js @@ -1,4 +1,4 @@ -!function(t){"function"==typeof define&&define.amd?define(t):t()}(function(){"use strict";var t="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function n(t,n){return t(n={exports:{}},n.exports),n.exports}var e=n(function(t){var n=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)}),r={}.hasOwnProperty,i=function(t,n){return r.call(t,n)},o=function(t){try{return!!t()}catch(t){return!0}},a=!o(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a}),u=n(function(t){var n=t.exports={version:"2.6.5"};"number"==typeof __e&&(__e=n)}),c=(u.version,function(t){return"object"==typeof t?null!==t:"function"==typeof t}),s=function(t){if(!c(t))throw TypeError(t+" is not an object!");return t},l=e.document,f=c(l)&&c(l.createElement),h=function(t){return f?l.createElement(t):{}},p=!a&&!o(function(){return 7!=Object.defineProperty(h("div"),"a",{get:function(){return 7}}).a}),v=function(t,n){if(!c(t))return t;var e,r;if(n&&"function"==typeof(e=t.toString)&&!c(r=e.call(t)))return r;if("function"==typeof(e=t.valueOf)&&!c(r=e.call(t)))return r;if(!n&&"function"==typeof(e=t.toString)&&!c(r=e.call(t)))return r;throw TypeError("Can't convert object to primitive value")},d=Object.defineProperty,g={f:a?Object.defineProperty:function(t,n,e){if(s(t),n=v(n,!0),s(e),p)try{return d(t,n,e)}catch(t){}if("get"in e||"set"in e)throw TypeError("Accessors not supported!");return"value"in e&&(t[n]=e.value),t}},y=function(t,n){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:n}},m=a?function(t,n,e){return g.f(t,n,y(1,e))}:function(t,n,e){return t[n]=e,t},b=0,_=Math.random(),w=function(t){return"Symbol(".concat(void 0===t?"":t,")_",(++b+_).toString(36))},S=n(function(t){var n=e["__core-js_shared__"]||(e["__core-js_shared__"]={});(t.exports=function(t,e){return n[t]||(n[t]=void 0!==e?e:{})})("versions",[]).push({version:u.version,mode:"global",copyright:"© 2019 Denis Pushkarev (zloirock.ru)"})}),x=S("native-function-to-string",Function.toString),E=n(function(t){var n=w("src"),r=(""+x).split("toString");u.inspectSource=function(t){return x.call(t)},(t.exports=function(t,o,a,u){var c="function"==typeof a;c&&(i(a,"name")||m(a,"name",o)),t[o]!==a&&(c&&(i(a,n)||m(a,n,t[o]?""+t[o]:r.join(String(o)))),t===e?t[o]=a:u?t[o]?t[o]=a:m(t,o,a):(delete t[o],m(t,o,a)))})(Function.prototype,"toString",function(){return"function"==typeof this&&this[n]||x.call(this)})}),P=function(t){if("function"!=typeof t)throw TypeError(t+" is not a function!");return t},O=function(t,n,e){if(P(t),void 0===n)return t;switch(e){case 1:return function(e){return t.call(n,e)};case 2:return function(e,r){return t.call(n,e,r)};case 3:return function(e,r,i){return t.call(n,e,r,i)}}return function(){return t.apply(n,arguments)}},F=function(t,n,r){var i,o,a,c,s=t&F.F,l=t&F.G,f=t&F.S,h=t&F.P,p=t&F.B,v=l?e:f?e[n]||(e[n]={}):(e[n]||{}).prototype,d=l?u:u[n]||(u[n]={}),g=d.prototype||(d.prototype={});for(i in l&&(r=n),r)a=((o=!s&&v&&void 0!==v[i])?v:r)[i],c=p&&o?O(a,e):h&&"function"==typeof a?O(Function.call,a):a,v&&E(v,i,a,t&F.U),d[i]!=a&&m(d,i,c),h&&g[i]!=a&&(g[i]=a)};e.core=u,F.F=1,F.G=2,F.S=4,F.P=8,F.B=16,F.W=32,F.U=64,F.R=128;var M=F,N=n(function(t){var n=w("meta"),e=g.f,r=0,a=Object.isExtensible||function(){return!0},u=!o(function(){return a(Object.preventExtensions({}))}),s=function(t){e(t,n,{value:{i:"O"+ ++r,w:{}}})},l=t.exports={KEY:n,NEED:!1,fastKey:function(t,e){if(!c(t))return"symbol"==typeof t?t:("string"==typeof t?"S":"P")+t;if(!i(t,n)){if(!a(t))return"F";if(!e)return"E";s(t)}return t[n].i},getWeak:function(t,e){if(!i(t,n)){if(!a(t))return!0;if(!e)return!1;s(t)}return t[n].w},onFreeze:function(t){return u&&l.NEED&&a(t)&&!i(t,n)&&s(t),t}}}),I=(N.KEY,N.NEED,N.fastKey,N.getWeak,N.onFreeze,n(function(t){var n=S("wks"),r=e.Symbol,i="function"==typeof r;(t.exports=function(t){return n[t]||(n[t]=i&&r[t]||(i?r:w)("Symbol."+t))}).store=n})),A=g.f,k=I("toStringTag"),j=function(t,n,e){t&&!i(t=e?t:t.prototype,k)&&A(t,k,{configurable:!0,value:n})},T={f:I},C=g.f,L=function(t){var n=u.Symbol||(u.Symbol=e.Symbol||{});"_"==t.charAt(0)||t in n||C(n,t,{value:T.f(t)})},R={}.toString,D=function(t){return R.call(t).slice(8,-1)},W=Object("z").propertyIsEnumerable(0)?Object:function(t){return"String"==D(t)?t.split(""):Object(t)},B=function(t){if(null==t)throw TypeError("Can't call method on "+t);return t},$=function(t){return W(B(t))},U=Math.ceil,V=Math.floor,G=function(t){return isNaN(t=+t)?0:(t>0?V:U)(t)},z=Math.min,q=function(t){return t>0?z(G(t),9007199254740991):0},H=Math.max,Y=Math.min,K=function(t,n){return(t=G(t))<0?H(t+n,0):Y(t,n)},J=function(t){return function(n,e,r){var i,o=$(n),a=q(o.length),u=K(r,a);if(t&&e!=e){for(;a>u;)if((i=o[u++])!=i)return!0}else for(;a>u;u++)if((t||u in o)&&o[u]===e)return t||u||0;return!t&&-1}},X=S("keys"),Z=function(t){return X[t]||(X[t]=w(t))},Q=J(!1),tt=Z("IE_PROTO"),nt=function(t,n){var e,r=$(t),o=0,a=[];for(e in r)e!=tt&&i(r,e)&&a.push(e);for(;n.length>o;)i(r,e=n[o++])&&(~Q(a,e)||a.push(e));return a},et="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(","),rt=Object.keys||function(t){return nt(t,et)},it={f:Object.getOwnPropertySymbols},ot={f:{}.propertyIsEnumerable},at=Array.isArray||function(t){return"Array"==D(t)},ut=a?Object.defineProperties:function(t,n){s(t);for(var e,r=rt(n),i=r.length,o=0;i>o;)g.f(t,e=r[o++],n[e]);return t},ct=e.document,st=ct&&ct.documentElement,lt=Z("IE_PROTO"),ft=function(){},ht=function(){var t,n=h("iframe"),e=et.length;for(n.style.display="none",st.appendChild(n),n.src="javascript:",(t=n.contentWindow.document).open(),t.write("