diff --git a/README.md b/README.md index 5f034b1..157a8e0 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,17 @@ This release also adds a limit configuration property to make the number of play Add device as a parameter (thanks Maxence Dunnewind @maxenced). **New from version 1.8** -Removed need for custom sensor from [My Spotify Chromecast custom component](https://github.com/fondberg/spotcast). +Removed need for custom sensor from [My Spotify Chromecast custom component](https://github.com/fondberg/spotcast). Fixed the reauth problem and added support for stopping pollimg Spotify APIs when the browser tab is hidden. Added transfer playback support and fixed a lot of bugs (amongst others security issues with dependencies). **New in version 1.9** -Support for [HACS](https://github.com/custom-components/hacs). +Support for [HACS](https://github.com/custom-components/hacs). + +**New in version 1.10** +- Support for showing featured playlists instead of users playlists. Use configuration parameter `featuredPlaylists` and set it to true. +- Define which account should be used when calling spotcast. This should be the account key name as defined in the configuration for spotcast. Default is to use default account. +- Possibility to set height with scrolling. New configuration parameter `height` which takes an integer value and renders the playlist element that height in pixels. ![Screenshot](/spotify-card-highlight.png) @@ -65,6 +70,9 @@ Now add the card like this: client_id: limit: device: + player: + featuredPlaylists: + height: ``` If you add the `device` setting, the card will select it by default and will not display the dropdown menu. diff --git a/dist/spotify-card.js b/dist/spotify-card.js index 357f27f..f6e2442 100644 --- a/dist/spotify-card.js +++ b/dist/spotify-card.js @@ -1,4 +1,4 @@ -!function(e){"function"==typeof define&&define.amd?define(["core-js/stable"],e):e()}(function(){"use strict";var e=function(){},t={},n=[],i=[];function o(t,o){var s,r,a,l,c=i;for(l=arguments.length;l-- >2;)n.push(arguments[l]);for(o&&null!=o.children&&(n.length||n.push(o.children),delete o.children);n.length;)if((r=n.pop())&&void 0!==r.pop)for(l=r.length;l--;)n.push(r[l]);else"boolean"==typeof r&&(r=null),(a="function"!=typeof t)&&(null==r?r="":"number"==typeof r?r=String(r):"string"!=typeof r&&(a=!1)),a&&s?c[c.length-1]+=r:c===i?c=[r]:c.push(r),s=a;var p=new e;return p.nodeName=t,p.children=c,p.attributes=null==o?void 0:o,p.key=null==o?void 0:o.key,p}function s(e,t){for(var n in t)e[n]=t[n];return e}function r(e,t){null!=e&&("function"==typeof e?e(t):e.current=t)}var a="function"==typeof Promise?Promise.resolve().then.bind(Promise.resolve()):setTimeout,l=/acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i,c=[];function p(e){!e._dirty&&(e._dirty=!0)&&1==c.push(e)&&a(d)}function d(){for(var e;e=c.pop();)e._dirty&&B(e)}function h(e,t,n){return"string"==typeof t||"number"==typeof t?void 0!==e.splitText:"string"==typeof t.nodeName?!e._componentConstructor&&u(e,t.nodeName):n||e._componentConstructor===t.nodeName}function u(e,t){return e.normalizedNodeName===t||e.nodeName.toLowerCase()===t.toLowerCase()}function f(e){var t=s({},e.attributes);t.children=e.children;var n=e.nodeName.defaultProps;if(void 0!==n)for(var i in n)void 0===t[i]&&(t[i]=n[i]);return t}function v(e){var t=e.parentNode;t&&t.removeChild(e)}function y(e,t,n,i,o){if("className"===t&&(t="class"),"key"===t);else if("ref"===t)r(n,null),r(i,e);else if("class"!==t||o)if("style"===t){if(i&&"string"!=typeof i&&"string"!=typeof n||(e.style.cssText=i||""),i&&"object"==typeof i){if("string"!=typeof n)for(var s in n)s in i||(e.style[s]="");for(var s in i)e.style[s]="number"==typeof i[s]&&!1===l.test(s)?i[s]+"px":i[s]}}else if("dangerouslySetInnerHTML"===t)i&&(e.innerHTML=i.__html||"");else if("o"==t[0]&&"n"==t[1]){var a=t!==(t=t.replace(/Capture$/,""));t=t.toLowerCase().substring(2),i?n||e.addEventListener(t,m,a):e.removeEventListener(t,m,a),(e._listeners||(e._listeners={}))[t]=i}else if("list"!==t&&"type"!==t&&!o&&t in e){try{e[t]=null==i?"":i}catch(e){}null!=i&&!1!==i||"spellcheck"==t||e.removeAttribute(t)}else{var c=o&&t!==(t=t.replace(/^xlink:?/,""));null==i||!1===i?c?e.removeAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase()):e.removeAttribute(t):"function"!=typeof i&&(c?e.setAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase(),i):e.setAttribute(t,i))}else e.className=i||""}function m(e){return this._listeners[e.type](e)}var g=[],_=0,b=!1,x=!1;function w(){for(var e;e=g.shift();)e.componentDidMount&&e.componentDidMount()}function C(e,t,n,i,o,s){_++||(b=null!=o&&void 0!==o.ownerSVGElement,x=null!=e&&!("__preactattr_"in e));var r=k(e,t,n,i,s);return o&&r.parentNode!==o&&o.appendChild(r),--_||(x=!1,s||w()),r}function k(e,t,n,i,o){var s=e,r=b;if(null!=t&&"boolean"!=typeof t||(t=""),"string"==typeof t||"number"==typeof t)return e&&void 0!==e.splitText&&e.parentNode&&(!e._component||o)?e.nodeValue!=t&&(e.nodeValue=t):(s=document.createTextNode(t),e&&(e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0))),s.__preactattr_=!0,s;var a,l,c=t.nodeName;if("function"==typeof c)return function(e,t,n,i){var o=e&&e._component,s=o,r=e,a=o&&e._componentConstructor===t.nodeName,l=a,c=f(t);for(;o&&!l&&(o=o._parentComponent);)l=o.constructor===t.nodeName;o&&l&&(!i||o._component)?(T(o,c,3,n,i),e=o.base):(s&&!a&&(E(s),e=r=null),o=D(t.nodeName,c,n),e&&!o.nextBase&&(o.nextBase=e,r=null),T(o,c,1,n,i),e=o.base,r&&e!==r&&(r._component=null,S(r,!1)));return e}(e,t,n,i);if(b="svg"===c||"foreignObject"!==c&&b,c=String(c),(!e||!u(e,c))&&(a=c,(l=b?document.createElementNS("http://www.w3.org/2000/svg",a):document.createElement(a)).normalizedNodeName=a,s=l,e)){for(;e.firstChild;)s.appendChild(e.firstChild);e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0)}var p=s.firstChild,d=s.__preactattr_,m=t.children;if(null==d){d=s.__preactattr_={};for(var g=s.attributes,_=g.length;_--;)d[g[_].name]=g[_].value}return!x&&m&&1===m.length&&"string"==typeof m[0]&&null!=p&&void 0!==p.splitText&&null==p.nextSibling?p.nodeValue!=m[0]&&(p.nodeValue=m[0]):(m&&m.length||null!=p)&&function(e,t,n,i,o){var s,r,a,l,c,p=e.childNodes,d=[],u={},f=0,y=0,m=p.length,g=0,_=t?t.length:0;if(0!==m)for(var b=0;b"===t?(a(),i=1):i&&("="===t?(i=4,n=o,o=""):"/"===t?(a(),3===i&&(r=r[0]),i=r,(r=r[0]).push(i,4),i=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(a(),i=2):o+=t)}return a(),r},z="function"==typeof Map,A=z?new Map:{},L=z?function(e){var t=A.get(e);return t||A.set(e,t=I(e)),t}:function(e){for(var t="",n=0;n1?t:t[0]}const W=R.bind(o);class q extends M{constructor(){super(),this.state={selectedDevice:"-- choose mediaplayer --",castEntities:[]}}componentWillUnmount(){"function"==typeof this.unsubscribeEntitites&&this.unsubscribeEntitites()}async componentDidMount(){await this.refreshCastEntities(),this.dataRefreshToken=setInterval(async()=>{await this.refreshCastEntities()},5e3)}async refreshCastEntities(){const e=(await this.props.hass.callWS({type:"config/entity_registry/list"})).filter(e=>"cast"==e.platform).map(e=>this.props.hass.states[e.entity_id]).filter(e=>null!=e);this.setState({castEntities:e})}componentWillUnmount(){clearInterval(this.dataRefreshToken)}componentWillReceiveProps(e,t){e.selectedDevice&&this.setState({selectedDevice:e.selectedDevice.name})}selectDevice(e){this.setState({selectedDevice:e.name}),this.props.onMediaplayerSelect(e)}selectChromecastDevice(e){this.props.onChromecastDeviceSelect(e)}render(){const{devices:e}=this.props,{castEntities:t}=this.state,n=W` +!function(e){"function"==typeof define&&define.amd?define(["core-js/stable"],e):e()}(function(){"use strict";var e=function(){},t={},n=[],i=[];function o(t,o){var s,r,a,l,c=i;for(l=arguments.length;l-- >2;)n.push(arguments[l]);for(o&&null!=o.children&&(n.length||n.push(o.children),delete o.children);n.length;)if((r=n.pop())&&void 0!==r.pop)for(l=r.length;l--;)n.push(r[l]);else"boolean"==typeof r&&(r=null),(a="function"!=typeof t)&&(null==r?r="":"number"==typeof r?r=String(r):"string"!=typeof r&&(a=!1)),a&&s?c[c.length-1]+=r:c===i?c=[r]:c.push(r),s=a;var p=new e;return p.nodeName=t,p.children=c,p.attributes=null==o?void 0:o,p.key=null==o?void 0:o.key,p}function s(e,t){for(var n in t)e[n]=t[n];return e}function r(e,t){null!=e&&("function"==typeof e?e(t):e.current=t)}var a="function"==typeof Promise?Promise.resolve().then.bind(Promise.resolve()):setTimeout,l=/acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i,c=[];function p(e){!e._dirty&&(e._dirty=!0)&&1==c.push(e)&&a(d)}function d(){for(var e;e=c.pop();)e._dirty&&L(e)}function h(e,t,n){return"string"==typeof t||"number"==typeof t?void 0!==e.splitText:"string"==typeof t.nodeName?!e._componentConstructor&&u(e,t.nodeName):n||e._componentConstructor===t.nodeName}function u(e,t){return e.normalizedNodeName===t||e.nodeName.toLowerCase()===t.toLowerCase()}function f(e){var t=s({},e.attributes);t.children=e.children;var n=e.nodeName.defaultProps;if(void 0!==n)for(var i in n)void 0===t[i]&&(t[i]=n[i]);return t}function y(e){var t=e.parentNode;t&&t.removeChild(e)}function m(e,t,n,i,o){if("className"===t&&(t="class"),"key"===t);else if("ref"===t)r(n,null),r(i,e);else if("class"!==t||o)if("style"===t){if(i&&"string"!=typeof i&&"string"!=typeof n||(e.style.cssText=i||""),i&&"object"==typeof i){if("string"!=typeof n)for(var s in n)s in i||(e.style[s]="");for(var s in i)e.style[s]="number"==typeof i[s]&&!1===l.test(s)?i[s]+"px":i[s]}}else if("dangerouslySetInnerHTML"===t)i&&(e.innerHTML=i.__html||"");else if("o"==t[0]&&"n"==t[1]){var a=t!==(t=t.replace(/Capture$/,""));t=t.toLowerCase().substring(2),i?n||e.addEventListener(t,v,a):e.removeEventListener(t,v,a),(e._listeners||(e._listeners={}))[t]=i}else if("list"!==t&&"type"!==t&&!o&&t in e){try{e[t]=null==i?"":i}catch(e){}null!=i&&!1!==i||"spellcheck"==t||e.removeAttribute(t)}else{var c=o&&t!==(t=t.replace(/^xlink:?/,""));null==i||!1===i?c?e.removeAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase()):e.removeAttribute(t):"function"!=typeof i&&(c?e.setAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase(),i):e.setAttribute(t,i))}else e.className=i||""}function v(e){return this._listeners[e.type](e)}var g=[],_=0,b=!1,x=!1;function w(){for(var e;e=g.shift();)e.componentDidMount&&e.componentDidMount()}function k(e,t,n,i,o,s){_++||(b=null!=o&&void 0!==o.ownerSVGElement,x=null!=e&&!("__preactattr_"in e));var r=C(e,t,n,i,s);return o&&r.parentNode!==o&&o.appendChild(r),--_||(x=!1,s||w()),r}function C(e,t,n,i,o){var s=e,r=b;if(null!=t&&"boolean"!=typeof t||(t=""),"string"==typeof t||"number"==typeof t)return e&&void 0!==e.splitText&&e.parentNode&&(!e._component||o)?e.nodeValue!=t&&(e.nodeValue=t):(s=document.createTextNode(t),e&&(e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0))),s.__preactattr_=!0,s;var a,l,c=t.nodeName;if("function"==typeof c)return function(e,t,n,i){var o=e&&e._component,s=o,r=e,a=o&&e._componentConstructor===t.nodeName,l=a,c=f(t);for(;o&&!l&&(o=o._parentComponent);)l=o.constructor===t.nodeName;o&&l&&(!i||o._component)?(T(o,c,3,n,i),e=o.base):(s&&!a&&(B(s),e=r=null),o=N(t.nodeName,c,n),e&&!o.nextBase&&(o.nextBase=e,r=null),T(o,c,1,n,i),e=o.base,r&&e!==r&&(r._component=null,S(r,!1)));return e}(e,t,n,i);if(b="svg"===c||"foreignObject"!==c&&b,c=String(c),(!e||!u(e,c))&&(a=c,(l=b?document.createElementNS("http://www.w3.org/2000/svg",a):document.createElement(a)).normalizedNodeName=a,s=l,e)){for(;e.firstChild;)s.appendChild(e.firstChild);e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0)}var p=s.firstChild,d=s.__preactattr_,v=t.children;if(null==d){d=s.__preactattr_={};for(var g=s.attributes,_=g.length;_--;)d[g[_].name]=g[_].value}return!x&&v&&1===v.length&&"string"==typeof v[0]&&null!=p&&void 0!==p.splitText&&null==p.nextSibling?p.nodeValue!=v[0]&&(p.nodeValue=v[0]):(v&&v.length||null!=p)&&function(e,t,n,i,o){var s,r,a,l,c,p=e.childNodes,d=[],u={},f=0,m=0,v=p.length,g=0,_=t?t.length:0;if(0!==v)for(var b=0;b"===t?(a(),i=1):i&&("="===t?(i=4,n=o,o=""):"/"===t?(a(),3===i&&(r=r[0]),i=r,(r=r[0]).push(i,4),i=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(a(),i=2):o+=t)}return a(),r},U="function"==typeof Map,z=U?new Map:{},A=U?function(e){var t=z.get(e);return t||z.set(e,t=M(e)),t}:function(e){for(var t="",n=0;n1?t:t[0]}const W=R.bind(o);class j extends E{constructor(){super(),this.state={selectedDevice:"-- choose mediaplayer --",castEntities:[]}}componentWillUnmount(){"function"==typeof this.unsubscribeEntitites&&this.unsubscribeEntitites()}async componentDidMount(){await this.refreshCastEntities(),this.dataRefreshToken=setInterval(async()=>{await this.refreshCastEntities()},5e3)}async refreshCastEntities(){const e=(await this.props.hass.callWS({type:"config/entity_registry/list"})).filter(e=>"cast"==e.platform).map(e=>this.props.hass.states[e.entity_id]).filter(e=>null!=e);this.setState({castEntities:e})}componentWillUnmount(){clearInterval(this.dataRefreshToken)}componentWillReceiveProps(e,t){e.selectedDevice&&this.setState({selectedDevice:e.selectedDevice.name})}selectDevice(e){this.setState({selectedDevice:e.name}),this.props.onMediaplayerSelect(e)}selectChromecastDevice(e){this.props.onChromecastDeviceSelect(e)}render(){const{devices:e}=this.props,{castEntities:t}=this.state,n=W` ${i} - `}}const H=R.bind(o);class j extends M{constructor(e){super(e),this.dataRefreshToken=null,this.state={playlists:[],devices:[],selectedDevice:null,selectedPlaylist:null,currentPlayer:null,playingPlaylist:null,authenticationRequired:!0},this.scopes=["playlist-read-private","user-read-playback-state","user-modify-playback-state"]}async componentDidMount(){document.addEventListener("visibilitychange",async()=>{document.hidden||(await this.checkAuthentication(),await this.refreshPlayData())}),await this.checkAuthentication(),await this.refreshPlayData(),this.dataRefreshToken=setInterval(async()=>{await this.refreshPlayData()},5e3)}componentWillUnmount(){clearInterval(this.dataRefreshToken)}async checkAuthentication(){const e=new URLSearchParams(window.location.hash.substring(1)),t=e.get("access_token")||localStorage.getItem("access_token"),n=localStorage.getItem("token_expires_ms"),i={Authorization:`Bearer ${t}`},o=await fetch("https://api.spotify.com/v1/me",{headers:i}).then(e=>e.json());if(o.error)return 401===o.error.status?t&&0+n-(new Date).getTime()<0?this.authenticateSpotify():this.setState({authenticationRequired:!0}):(console.error("This should never happen:",response),this.setState({error:response.error}));if(e.get("access_token")){const n=e.get("expires_in");localStorage.setItem("access_token",t),localStorage.setItem("token_expires_ms",(new Date).getTime()+1e3*n);const i=window.location.href.split("#")[0];window.history.pushState({path:i},"",i)}this.setState({authenticationRequired:!1})}async refreshPlayData(){if(document.hidden)return;await this.checkAuthentication();const e={Authorization:`Bearer ${localStorage.getItem("access_token")}`},t=await fetch("https://api.spotify.com/v1/me/playlists?limit="+this.props.limit,{headers:e}).then(e=>e.json()).then(e=>e.items),n=await fetch("https://api.spotify.com/v1/me/player/devices",{headers:e}).then(e=>e.json()).then(e=>e.devices),i=await fetch("https://api.spotify.com/v1/me/player",{headers:e});let o,s=null,r=null;if(200===i.status&&(o=(r=await i.json()).device,r.context&&r.context.external_urls)){const e=r.context.external_urls.spotify;s=t.find(t=>e===t.external_urls.spotify)}this.setState({playlists:t,devices:n,selectedDevice:o,playingPlaylist:s,currentPlayer:r})}authenticateSpotify(){const e=window.location.href.split("?")[0],{clientId:t}=this.props;window.location.href=`https://accounts.spotify.com/authorize?client_id=${t}&redirect_uri=${e}&scope=${this.scopes.join("%20")}&response_type=token`}playPlaylist(){const{selectedPlaylist:e,selectedDevice:t}=this.state;e&&t?fetch("https://api.spotify.com/v1/me/player/play?device_id="+t.id,{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem("access_token")}`},body:JSON.stringify({context_uri:e.uri})}).then(()=>this.setState({playingPlaylist:e})):console.error("Will not play because there is no playlist or device selected,",e,t)}onPlaylistSelect(e){this.setState({selectedPlaylist:e}),this.playPlaylist()}onMediaPlayerSelect(e){this.setState({selectedDevice:e}),this.state.currentPlayer&&fetch("https://api.spotify.com/v1/me/player",{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem("access_token")}`},body:JSON.stringify({device_ids:[e.id],play:!0})})}onChromecastDeviceSelect(e){const t=this.state.playingPlaylist?this.state.playingPlaylist:this.state.playlists[0];t?this.props.hass.callService("spotcast","start",{device_name:e,uri:t.uri,transfer_playback:null!=this.state.currentPlayer}):console.error("Nothing to play, skipping starting chromecast device")}getHighlighted(e){const{selectedPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"highlight":""}getIsPlayingClass(e){const{playingPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"playing":""}render(){const{authenticationRequired:e,playlists:t,devices:n,selectedDevice:i}=this.state;return e?H` + `}}const q=R.bind(o);class H extends E{constructor(e){super(e),this.dataRefreshToken=null,this.state={playlists:[],devices:[],selectedDevice:null,selectedPlaylist:null,currentPlayer:null,playingPlaylist:null,authenticationRequired:!0},this.scopes=["playlist-read-private","playlist-read-collaborative","user-read-playback-state","user-modify-playback-state"]}async componentDidMount(){document.addEventListener("visibilitychange",async()=>{document.hidden||(await this.checkAuthentication(),await this.refreshPlayData())}),await this.checkAuthentication(),await this.refreshPlayData(),this.dataRefreshToken=setInterval(async()=>{await this.refreshPlayData()},5e3)}componentWillUnmount(){clearInterval(this.dataRefreshToken)}getLocalStorageTokenName(){return"spotify-access_token-"+(this.props.account?this.props.account:"default")}async checkAuthentication(){const e=new URLSearchParams(window.location.hash.substring(1)),t=e.get("access_token")||localStorage.getItem(this.getLocalStorageTokenName()),n=localStorage.getItem(this.getLocalStorageTokenName()+"-expires_ms"),i={Authorization:`Bearer ${t}`},o=await fetch("https://api.spotify.com/v1/me",{headers:i}).then(e=>e.json());if(o.error)return 401===o.error.status?t&&0+n-(new Date).getTime()<0?this.authenticateSpotify():this.setState({authenticationRequired:!0}):(console.error("This should never happen:",response),this.setState({error:response.error}));if(e.get("access_token")){const n=e.get("expires_in");localStorage.setItem(this.getLocalStorageTokenName(),t),localStorage.setItem(this.getLocalStorageTokenName()+"-expires_ms",(new Date).getTime()+1e3*n);const i=window.location.href.split("#")[0];window.history.pushState({path:i},"",i)}this.setState({authenticationRequired:!1})}async refreshPlayData(){if(document.hidden)return;await this.checkAuthentication();const e={Authorization:`Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`};let t;t=this.props.featuredPlaylists?await fetch("https://api.spotify.com/v1/browse/featured-playlists?limit="+this.props.limit,{headers:e}).then(e=>e.json()).then(e=>e.playlists.items):await fetch("https://api.spotify.com/v1/me/playlists?limit="+this.props.limit,{headers:e}).then(e=>e.json()).then(e=>e.items);const n=await fetch("https://api.spotify.com/v1/me/player/devices",{headers:e}).then(e=>e.json()).then(e=>e.devices),i=await fetch("https://api.spotify.com/v1/me/player",{headers:e});let o,s=null,r=null;if(200===i.status&&(o=(r=await i.json()).device,r.context&&r.context.external_urls)){const e=r.context.external_urls.spotify;s=t.find(t=>e===t.external_urls.spotify)}this.setState({playlists:t,devices:n,selectedDevice:o,playingPlaylist:s,currentPlayer:r})}authenticateSpotify(){const e=window.location.href.split("?")[0],{clientId:t}=this.props;window.location.href=`https://accounts.spotify.com/authorize?client_id=${t}&redirect_uri=${e}&scope=${this.scopes.join("%20")}&response_type=token`}playPlaylist(){const{selectedPlaylist:e,selectedDevice:t}=this.state;e&&t?fetch("https://api.spotify.com/v1/me/player/play?device_id="+t.id,{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`},body:JSON.stringify({context_uri:e.uri})}).then(()=>this.setState({playingPlaylist:e})):console.error("Will not play because there is no playlist or device selected,",e,t)}onPlaylistSelect(e){this.setState({selectedPlaylist:e}),this.playPlaylist()}onMediaPlayerSelect(e){this.setState({selectedDevice:e}),this.state.currentPlayer&&fetch("https://api.spotify.com/v1/me/player",{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`},body:JSON.stringify({device_ids:[e.id],play:!0})})}onChromecastDeviceSelect(e){const t=this.state.playingPlaylist?this.state.playingPlaylist:this.state.playlists[0];if(!t)return void console.error("Nothing to play, skipping starting chromecast device");const n={device_name:e,uri:t.uri,transfer_playback:null!=this.state.currentPlayer};this.props.account&&(n.account=this.props.account),this.props.hass.callService("spotcast","start",n)}getHighlighted(e){const{selectedPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"highlight":""}getIsPlayingClass(e){const{playingPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"playing":""}render(){const{authenticationRequired:e,playlists:t,devices:n,selectedDevice:i}=this.state;if(e)return q`
<${V} />
- `:H` + `;const o={height:this.props.height?parseInt(this.props.height):"auto"};return q`
<${V} /> -
- ${t.map((e,t)=>{const n=e.images[0]?e.images[0].url:"https://via.placeholder.com/150x150.png?text=No+image";return H` +
+ ${t.map((e,t)=>{const n=e.images[0]?e.images[0].url:"https://via.placeholder.com/150x150.png?text=No+image";return q`
this.onPlaylistSelect(e,t,n,this)} @@ -55,7 +55,7 @@ `})}
- <${q} + <${j} devices=${n} selectedDevice=${i} hass=${this.props.hass} @@ -65,15 +65,18 @@ />
- `}}const V=()=>H` + `}}const V=()=>q`
-`,O=document.createElement("style"),F="rgb(30, 215, 96)",G="rgb(40, 40, 40)",J="rgb(24, 24, 24)",K="rgb(170, 170, 170)",Q="rgb(200, 200, 200)",X="rgb(255, 255, 255)";O.textContent=`\n .spotify_container {\n background-color: ${G};\n font-family: 'Roboto', sans-serif;\n color: ${X};\n font-size: 14px;\n padding: 25px;\n }\n .spotify_container *:focus {outline:none}\n .header img {\n height: 30px;\n margin-bottom: 10px;\n }\n .login__box {\n width: 100%;\n text-align: center;\n }\n .playlists {\n display: flex;\n flex-flow: column nowrap;\n margin-bottom: 15px;\n background-color: ${J};\n }\n .playlist {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n border-top: 1px solid ${G};\n height: 42px;\n }\n .playlist:active {\n background-color: rgb(200, 200, 240);\n }\n .playlist:last-child {\n border-bottom: 1px solid ${G};\n }\n .playlist:hover {\n background: ${G};\n cursor: pointer;\n }\n .highlight {\n background: ${G};\n }\n\n .playlist__cover_art img {\n width: 42px;\n height: 42px;\n }\n .playlist__number {\n margin-left: 10px;\n color: ${K};\n width: 12px;\n }\n\n .playlist__playicon {\n color: ${X};\n margin-left: 10px;\n }\n .playlist__playicon:hover {\n color: rgb(216, 255, 229);\n text-shadow: 0 0 20px rgb(216, 255, 229);\n }\n .playing {\n color: ${F}\n }\n\n .playlist__title {\n margin-left: 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 75vw;\n }\n .controls {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n }\n .greenButton {\n border-radius: 15px;\n padding: 0 20px 0 20px;\n font-size: 14px;\n height: 27px;\n color: white;\n border: none;\n background: ${F};\n cursor: pointer;\n margin-right: 10px;\n }\n .greenButton:hover {\n background-color: #43e57d;\n }\n .playButton::before {\n content: "\\25B6 "\n }\n\n .dropdown {\n position: relative;\n display: inline-block;\n color: ${Q};\n }\n .mediaplayer_select {\n display: flex;\n align-items: center;\n justify-content: center;\n }\n .mediaplayer_speaker_icon {\n display: inline-block;\n padding: 3px;\n width: 17px;\n height: 17px;\n margin-right: 10px;\n border: thin solid ${Q};\n border-radius: 50%;\n }\n .dropdown-content {\n display: none;\n position: absolute;\n background-color: ${G};\n min-width: 250px;\n box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);\n z-index: 1;\n }\n .dropdown-content a {\n color: ${Q};\n padding: 12px 16px;\n text-decoration: none;\n display: block;\n }\n .dropdown-content a:hover {\n box-shadow: inset 0 0 100px 100px rgba(255, 255, 255, 0.07);\n }\n .dropdown:hover .dropdown-content {\n display: block;\n }\n`;customElements.define("spotify-card",class extends HTMLElement{constructor(){super(),this.shadow=this.attachShadow({mode:"open"}),this.config={}}set hass(e){this.savedHass||(this.savedHass=e)}setConfig(e){if(!e.client_id)throw new Error("No client ---- id");this.config=e}getCardSize(){return 10}disconnectedCallback(){this.shadow.innerHTML=""}connectedCallback(){this.config.client_id||(this.config.client_id=this.getAttribute("client_id"));const e=document.createElement("div");var t,n;this.shadow.appendChild(O),this.shadow.appendChild(e),t=H` - <${j} +`,O=R.bind(o),F=document.createElement("style"),G="rgb(30, 215, 96)",J="rgb(40, 40, 40)",K="rgb(24, 24, 24)",Q="rgb(170, 170, 170)",X="rgb(200, 200, 200)",Y="rgb(255, 255, 255)";F.textContent=`\n .spotify_container {\n background-color: ${J};\n font-family: 'Roboto', sans-serif;\n color: ${Y};\n font-size: 14px;\n padding: 25px;\n }\n .spotify_container *:focus {outline:none}\n .header img {\n height: 30px;\n margin-bottom: 10px;\n }\n .login__box {\n width: 100%;\n text-align: center;\n }\n .playlists {\n display: flex;\n flex-flow: column nowrap;\n margin-bottom: 15px;\n background-color: ${K};\n overflow: scroll;\n }\n .playlist {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n border-top: 1px solid ${J};\n height: 42px;\n }\n .playlist:active {\n background-color: rgb(200, 200, 240);\n }\n .playlist:last-child {\n border-bottom: 1px solid ${J};\n }\n .playlist:hover {\n background: ${J};\n cursor: pointer;\n }\n .highlight {\n background: ${J};\n }\n\n .playlist__cover_art img {\n width: 42px;\n height: 42px;\n }\n .playlist__number {\n margin-left: 10px;\n color: ${Q};\n width: 12px;\n }\n\n .playlist__playicon {\n color: ${Y};\n margin-left: 10px;\n }\n .playlist__playicon:hover {\n color: rgb(216, 255, 229);\n text-shadow: 0 0 20px rgb(216, 255, 229);\n }\n .playing {\n color: ${G}\n }\n\n .playlist__title {\n margin-left: 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 75vw;\n }\n .controls {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n }\n .greenButton {\n border-radius: 15px;\n padding: 0 20px 0 20px;\n font-size: 14px;\n height: 27px;\n color: white;\n border: none;\n background: ${G};\n cursor: pointer;\n margin-right: 10px;\n }\n .greenButton:hover {\n background-color: #43e57d;\n }\n .playButton::before {\n content: "\\25B6 "\n }\n\n .dropdown {\n position: relative;\n display: inline-block;\n color: ${X};\n }\n .mediaplayer_select {\n display: flex;\n align-items: center;\n justify-content: center;\n }\n .mediaplayer_speaker_icon {\n display: inline-block;\n padding: 3px;\n width: 17px;\n height: 17px;\n margin-right: 10px;\n border: thin solid ${X};\n border-radius: 50%;\n }\n .dropdown-content {\n display: none;\n position: absolute;\n background-color: ${J};\n min-width: 250px;\n box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);\n z-index: 1;\n }\n .dropdown-content a {\n color: ${X};\n padding: 12px 16px;\n text-decoration: none;\n display: block;\n }\n .dropdown-content a:hover {\n box-shadow: inset 0 0 100px 100px rgba(255, 255, 255, 0.07);\n }\n .dropdown:hover .dropdown-content {\n display: block;\n }\n`;customElements.define("spotify-card",class extends HTMLElement{constructor(){super(),this.shadow=this.attachShadow({mode:"open"}),this.config={}}set hass(e){this.savedHass||(this.savedHass=e)}setConfig(e){if(!e.client_id)throw new Error("No client ---- id");this.config=e}getCardSize(){return 10}disconnectedCallback(){this.shadow.innerHTML=""}connectedCallback(){this.config.client_id||(this.config.client_id=this.getAttribute("client_id"));const e=document.createElement("div");var t,n;this.shadow.appendChild(F),this.shadow.appendChild(e),t=O` + <${H} clientId=${this.config.client_id} limit=${this.config.limit||10} player=${this.config.device||"*"} hass=${this.savedHass} + featuredPlaylists=${this.config.featuredPlaylists||!1} + account=${this.config.account||""} + height=${this.config.height||""} /> - `,C(n,t,{},!1,e,!1)}})}); + `,k(n,t,{},!1,e,!1)}})}); diff --git a/dist/spotify-card.umd.js b/dist/spotify-card.umd.js index 357f27f..f6e2442 100644 --- a/dist/spotify-card.umd.js +++ b/dist/spotify-card.umd.js @@ -1,4 +1,4 @@ -!function(e){"function"==typeof define&&define.amd?define(["core-js/stable"],e):e()}(function(){"use strict";var e=function(){},t={},n=[],i=[];function o(t,o){var s,r,a,l,c=i;for(l=arguments.length;l-- >2;)n.push(arguments[l]);for(o&&null!=o.children&&(n.length||n.push(o.children),delete o.children);n.length;)if((r=n.pop())&&void 0!==r.pop)for(l=r.length;l--;)n.push(r[l]);else"boolean"==typeof r&&(r=null),(a="function"!=typeof t)&&(null==r?r="":"number"==typeof r?r=String(r):"string"!=typeof r&&(a=!1)),a&&s?c[c.length-1]+=r:c===i?c=[r]:c.push(r),s=a;var p=new e;return p.nodeName=t,p.children=c,p.attributes=null==o?void 0:o,p.key=null==o?void 0:o.key,p}function s(e,t){for(var n in t)e[n]=t[n];return e}function r(e,t){null!=e&&("function"==typeof e?e(t):e.current=t)}var a="function"==typeof Promise?Promise.resolve().then.bind(Promise.resolve()):setTimeout,l=/acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i,c=[];function p(e){!e._dirty&&(e._dirty=!0)&&1==c.push(e)&&a(d)}function d(){for(var e;e=c.pop();)e._dirty&&B(e)}function h(e,t,n){return"string"==typeof t||"number"==typeof t?void 0!==e.splitText:"string"==typeof t.nodeName?!e._componentConstructor&&u(e,t.nodeName):n||e._componentConstructor===t.nodeName}function u(e,t){return e.normalizedNodeName===t||e.nodeName.toLowerCase()===t.toLowerCase()}function f(e){var t=s({},e.attributes);t.children=e.children;var n=e.nodeName.defaultProps;if(void 0!==n)for(var i in n)void 0===t[i]&&(t[i]=n[i]);return t}function v(e){var t=e.parentNode;t&&t.removeChild(e)}function y(e,t,n,i,o){if("className"===t&&(t="class"),"key"===t);else if("ref"===t)r(n,null),r(i,e);else if("class"!==t||o)if("style"===t){if(i&&"string"!=typeof i&&"string"!=typeof n||(e.style.cssText=i||""),i&&"object"==typeof i){if("string"!=typeof n)for(var s in n)s in i||(e.style[s]="");for(var s in i)e.style[s]="number"==typeof i[s]&&!1===l.test(s)?i[s]+"px":i[s]}}else if("dangerouslySetInnerHTML"===t)i&&(e.innerHTML=i.__html||"");else if("o"==t[0]&&"n"==t[1]){var a=t!==(t=t.replace(/Capture$/,""));t=t.toLowerCase().substring(2),i?n||e.addEventListener(t,m,a):e.removeEventListener(t,m,a),(e._listeners||(e._listeners={}))[t]=i}else if("list"!==t&&"type"!==t&&!o&&t in e){try{e[t]=null==i?"":i}catch(e){}null!=i&&!1!==i||"spellcheck"==t||e.removeAttribute(t)}else{var c=o&&t!==(t=t.replace(/^xlink:?/,""));null==i||!1===i?c?e.removeAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase()):e.removeAttribute(t):"function"!=typeof i&&(c?e.setAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase(),i):e.setAttribute(t,i))}else e.className=i||""}function m(e){return this._listeners[e.type](e)}var g=[],_=0,b=!1,x=!1;function w(){for(var e;e=g.shift();)e.componentDidMount&&e.componentDidMount()}function C(e,t,n,i,o,s){_++||(b=null!=o&&void 0!==o.ownerSVGElement,x=null!=e&&!("__preactattr_"in e));var r=k(e,t,n,i,s);return o&&r.parentNode!==o&&o.appendChild(r),--_||(x=!1,s||w()),r}function k(e,t,n,i,o){var s=e,r=b;if(null!=t&&"boolean"!=typeof t||(t=""),"string"==typeof t||"number"==typeof t)return e&&void 0!==e.splitText&&e.parentNode&&(!e._component||o)?e.nodeValue!=t&&(e.nodeValue=t):(s=document.createTextNode(t),e&&(e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0))),s.__preactattr_=!0,s;var a,l,c=t.nodeName;if("function"==typeof c)return function(e,t,n,i){var o=e&&e._component,s=o,r=e,a=o&&e._componentConstructor===t.nodeName,l=a,c=f(t);for(;o&&!l&&(o=o._parentComponent);)l=o.constructor===t.nodeName;o&&l&&(!i||o._component)?(T(o,c,3,n,i),e=o.base):(s&&!a&&(E(s),e=r=null),o=D(t.nodeName,c,n),e&&!o.nextBase&&(o.nextBase=e,r=null),T(o,c,1,n,i),e=o.base,r&&e!==r&&(r._component=null,S(r,!1)));return e}(e,t,n,i);if(b="svg"===c||"foreignObject"!==c&&b,c=String(c),(!e||!u(e,c))&&(a=c,(l=b?document.createElementNS("http://www.w3.org/2000/svg",a):document.createElement(a)).normalizedNodeName=a,s=l,e)){for(;e.firstChild;)s.appendChild(e.firstChild);e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0)}var p=s.firstChild,d=s.__preactattr_,m=t.children;if(null==d){d=s.__preactattr_={};for(var g=s.attributes,_=g.length;_--;)d[g[_].name]=g[_].value}return!x&&m&&1===m.length&&"string"==typeof m[0]&&null!=p&&void 0!==p.splitText&&null==p.nextSibling?p.nodeValue!=m[0]&&(p.nodeValue=m[0]):(m&&m.length||null!=p)&&function(e,t,n,i,o){var s,r,a,l,c,p=e.childNodes,d=[],u={},f=0,y=0,m=p.length,g=0,_=t?t.length:0;if(0!==m)for(var b=0;b"===t?(a(),i=1):i&&("="===t?(i=4,n=o,o=""):"/"===t?(a(),3===i&&(r=r[0]),i=r,(r=r[0]).push(i,4),i=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(a(),i=2):o+=t)}return a(),r},z="function"==typeof Map,A=z?new Map:{},L=z?function(e){var t=A.get(e);return t||A.set(e,t=I(e)),t}:function(e){for(var t="",n=0;n1?t:t[0]}const W=R.bind(o);class q extends M{constructor(){super(),this.state={selectedDevice:"-- choose mediaplayer --",castEntities:[]}}componentWillUnmount(){"function"==typeof this.unsubscribeEntitites&&this.unsubscribeEntitites()}async componentDidMount(){await this.refreshCastEntities(),this.dataRefreshToken=setInterval(async()=>{await this.refreshCastEntities()},5e3)}async refreshCastEntities(){const e=(await this.props.hass.callWS({type:"config/entity_registry/list"})).filter(e=>"cast"==e.platform).map(e=>this.props.hass.states[e.entity_id]).filter(e=>null!=e);this.setState({castEntities:e})}componentWillUnmount(){clearInterval(this.dataRefreshToken)}componentWillReceiveProps(e,t){e.selectedDevice&&this.setState({selectedDevice:e.selectedDevice.name})}selectDevice(e){this.setState({selectedDevice:e.name}),this.props.onMediaplayerSelect(e)}selectChromecastDevice(e){this.props.onChromecastDeviceSelect(e)}render(){const{devices:e}=this.props,{castEntities:t}=this.state,n=W` +!function(e){"function"==typeof define&&define.amd?define(["core-js/stable"],e):e()}(function(){"use strict";var e=function(){},t={},n=[],i=[];function o(t,o){var s,r,a,l,c=i;for(l=arguments.length;l-- >2;)n.push(arguments[l]);for(o&&null!=o.children&&(n.length||n.push(o.children),delete o.children);n.length;)if((r=n.pop())&&void 0!==r.pop)for(l=r.length;l--;)n.push(r[l]);else"boolean"==typeof r&&(r=null),(a="function"!=typeof t)&&(null==r?r="":"number"==typeof r?r=String(r):"string"!=typeof r&&(a=!1)),a&&s?c[c.length-1]+=r:c===i?c=[r]:c.push(r),s=a;var p=new e;return p.nodeName=t,p.children=c,p.attributes=null==o?void 0:o,p.key=null==o?void 0:o.key,p}function s(e,t){for(var n in t)e[n]=t[n];return e}function r(e,t){null!=e&&("function"==typeof e?e(t):e.current=t)}var a="function"==typeof Promise?Promise.resolve().then.bind(Promise.resolve()):setTimeout,l=/acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i,c=[];function p(e){!e._dirty&&(e._dirty=!0)&&1==c.push(e)&&a(d)}function d(){for(var e;e=c.pop();)e._dirty&&L(e)}function h(e,t,n){return"string"==typeof t||"number"==typeof t?void 0!==e.splitText:"string"==typeof t.nodeName?!e._componentConstructor&&u(e,t.nodeName):n||e._componentConstructor===t.nodeName}function u(e,t){return e.normalizedNodeName===t||e.nodeName.toLowerCase()===t.toLowerCase()}function f(e){var t=s({},e.attributes);t.children=e.children;var n=e.nodeName.defaultProps;if(void 0!==n)for(var i in n)void 0===t[i]&&(t[i]=n[i]);return t}function y(e){var t=e.parentNode;t&&t.removeChild(e)}function m(e,t,n,i,o){if("className"===t&&(t="class"),"key"===t);else if("ref"===t)r(n,null),r(i,e);else if("class"!==t||o)if("style"===t){if(i&&"string"!=typeof i&&"string"!=typeof n||(e.style.cssText=i||""),i&&"object"==typeof i){if("string"!=typeof n)for(var s in n)s in i||(e.style[s]="");for(var s in i)e.style[s]="number"==typeof i[s]&&!1===l.test(s)?i[s]+"px":i[s]}}else if("dangerouslySetInnerHTML"===t)i&&(e.innerHTML=i.__html||"");else if("o"==t[0]&&"n"==t[1]){var a=t!==(t=t.replace(/Capture$/,""));t=t.toLowerCase().substring(2),i?n||e.addEventListener(t,v,a):e.removeEventListener(t,v,a),(e._listeners||(e._listeners={}))[t]=i}else if("list"!==t&&"type"!==t&&!o&&t in e){try{e[t]=null==i?"":i}catch(e){}null!=i&&!1!==i||"spellcheck"==t||e.removeAttribute(t)}else{var c=o&&t!==(t=t.replace(/^xlink:?/,""));null==i||!1===i?c?e.removeAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase()):e.removeAttribute(t):"function"!=typeof i&&(c?e.setAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase(),i):e.setAttribute(t,i))}else e.className=i||""}function v(e){return this._listeners[e.type](e)}var g=[],_=0,b=!1,x=!1;function w(){for(var e;e=g.shift();)e.componentDidMount&&e.componentDidMount()}function k(e,t,n,i,o,s){_++||(b=null!=o&&void 0!==o.ownerSVGElement,x=null!=e&&!("__preactattr_"in e));var r=C(e,t,n,i,s);return o&&r.parentNode!==o&&o.appendChild(r),--_||(x=!1,s||w()),r}function C(e,t,n,i,o){var s=e,r=b;if(null!=t&&"boolean"!=typeof t||(t=""),"string"==typeof t||"number"==typeof t)return e&&void 0!==e.splitText&&e.parentNode&&(!e._component||o)?e.nodeValue!=t&&(e.nodeValue=t):(s=document.createTextNode(t),e&&(e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0))),s.__preactattr_=!0,s;var a,l,c=t.nodeName;if("function"==typeof c)return function(e,t,n,i){var o=e&&e._component,s=o,r=e,a=o&&e._componentConstructor===t.nodeName,l=a,c=f(t);for(;o&&!l&&(o=o._parentComponent);)l=o.constructor===t.nodeName;o&&l&&(!i||o._component)?(T(o,c,3,n,i),e=o.base):(s&&!a&&(B(s),e=r=null),o=N(t.nodeName,c,n),e&&!o.nextBase&&(o.nextBase=e,r=null),T(o,c,1,n,i),e=o.base,r&&e!==r&&(r._component=null,S(r,!1)));return e}(e,t,n,i);if(b="svg"===c||"foreignObject"!==c&&b,c=String(c),(!e||!u(e,c))&&(a=c,(l=b?document.createElementNS("http://www.w3.org/2000/svg",a):document.createElement(a)).normalizedNodeName=a,s=l,e)){for(;e.firstChild;)s.appendChild(e.firstChild);e.parentNode&&e.parentNode.replaceChild(s,e),S(e,!0)}var p=s.firstChild,d=s.__preactattr_,v=t.children;if(null==d){d=s.__preactattr_={};for(var g=s.attributes,_=g.length;_--;)d[g[_].name]=g[_].value}return!x&&v&&1===v.length&&"string"==typeof v[0]&&null!=p&&void 0!==p.splitText&&null==p.nextSibling?p.nodeValue!=v[0]&&(p.nodeValue=v[0]):(v&&v.length||null!=p)&&function(e,t,n,i,o){var s,r,a,l,c,p=e.childNodes,d=[],u={},f=0,m=0,v=p.length,g=0,_=t?t.length:0;if(0!==v)for(var b=0;b"===t?(a(),i=1):i&&("="===t?(i=4,n=o,o=""):"/"===t?(a(),3===i&&(r=r[0]),i=r,(r=r[0]).push(i,4),i=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(a(),i=2):o+=t)}return a(),r},U="function"==typeof Map,z=U?new Map:{},A=U?function(e){var t=z.get(e);return t||z.set(e,t=M(e)),t}:function(e){for(var t="",n=0;n1?t:t[0]}const W=R.bind(o);class j extends E{constructor(){super(),this.state={selectedDevice:"-- choose mediaplayer --",castEntities:[]}}componentWillUnmount(){"function"==typeof this.unsubscribeEntitites&&this.unsubscribeEntitites()}async componentDidMount(){await this.refreshCastEntities(),this.dataRefreshToken=setInterval(async()=>{await this.refreshCastEntities()},5e3)}async refreshCastEntities(){const e=(await this.props.hass.callWS({type:"config/entity_registry/list"})).filter(e=>"cast"==e.platform).map(e=>this.props.hass.states[e.entity_id]).filter(e=>null!=e);this.setState({castEntities:e})}componentWillUnmount(){clearInterval(this.dataRefreshToken)}componentWillReceiveProps(e,t){e.selectedDevice&&this.setState({selectedDevice:e.selectedDevice.name})}selectDevice(e){this.setState({selectedDevice:e.name}),this.props.onMediaplayerSelect(e)}selectChromecastDevice(e){this.props.onChromecastDeviceSelect(e)}render(){const{devices:e}=this.props,{castEntities:t}=this.state,n=W` ${i}
- `}}const H=R.bind(o);class j extends M{constructor(e){super(e),this.dataRefreshToken=null,this.state={playlists:[],devices:[],selectedDevice:null,selectedPlaylist:null,currentPlayer:null,playingPlaylist:null,authenticationRequired:!0},this.scopes=["playlist-read-private","user-read-playback-state","user-modify-playback-state"]}async componentDidMount(){document.addEventListener("visibilitychange",async()=>{document.hidden||(await this.checkAuthentication(),await this.refreshPlayData())}),await this.checkAuthentication(),await this.refreshPlayData(),this.dataRefreshToken=setInterval(async()=>{await this.refreshPlayData()},5e3)}componentWillUnmount(){clearInterval(this.dataRefreshToken)}async checkAuthentication(){const e=new URLSearchParams(window.location.hash.substring(1)),t=e.get("access_token")||localStorage.getItem("access_token"),n=localStorage.getItem("token_expires_ms"),i={Authorization:`Bearer ${t}`},o=await fetch("https://api.spotify.com/v1/me",{headers:i}).then(e=>e.json());if(o.error)return 401===o.error.status?t&&0+n-(new Date).getTime()<0?this.authenticateSpotify():this.setState({authenticationRequired:!0}):(console.error("This should never happen:",response),this.setState({error:response.error}));if(e.get("access_token")){const n=e.get("expires_in");localStorage.setItem("access_token",t),localStorage.setItem("token_expires_ms",(new Date).getTime()+1e3*n);const i=window.location.href.split("#")[0];window.history.pushState({path:i},"",i)}this.setState({authenticationRequired:!1})}async refreshPlayData(){if(document.hidden)return;await this.checkAuthentication();const e={Authorization:`Bearer ${localStorage.getItem("access_token")}`},t=await fetch("https://api.spotify.com/v1/me/playlists?limit="+this.props.limit,{headers:e}).then(e=>e.json()).then(e=>e.items),n=await fetch("https://api.spotify.com/v1/me/player/devices",{headers:e}).then(e=>e.json()).then(e=>e.devices),i=await fetch("https://api.spotify.com/v1/me/player",{headers:e});let o,s=null,r=null;if(200===i.status&&(o=(r=await i.json()).device,r.context&&r.context.external_urls)){const e=r.context.external_urls.spotify;s=t.find(t=>e===t.external_urls.spotify)}this.setState({playlists:t,devices:n,selectedDevice:o,playingPlaylist:s,currentPlayer:r})}authenticateSpotify(){const e=window.location.href.split("?")[0],{clientId:t}=this.props;window.location.href=`https://accounts.spotify.com/authorize?client_id=${t}&redirect_uri=${e}&scope=${this.scopes.join("%20")}&response_type=token`}playPlaylist(){const{selectedPlaylist:e,selectedDevice:t}=this.state;e&&t?fetch("https://api.spotify.com/v1/me/player/play?device_id="+t.id,{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem("access_token")}`},body:JSON.stringify({context_uri:e.uri})}).then(()=>this.setState({playingPlaylist:e})):console.error("Will not play because there is no playlist or device selected,",e,t)}onPlaylistSelect(e){this.setState({selectedPlaylist:e}),this.playPlaylist()}onMediaPlayerSelect(e){this.setState({selectedDevice:e}),this.state.currentPlayer&&fetch("https://api.spotify.com/v1/me/player",{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem("access_token")}`},body:JSON.stringify({device_ids:[e.id],play:!0})})}onChromecastDeviceSelect(e){const t=this.state.playingPlaylist?this.state.playingPlaylist:this.state.playlists[0];t?this.props.hass.callService("spotcast","start",{device_name:e,uri:t.uri,transfer_playback:null!=this.state.currentPlayer}):console.error("Nothing to play, skipping starting chromecast device")}getHighlighted(e){const{selectedPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"highlight":""}getIsPlayingClass(e){const{playingPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"playing":""}render(){const{authenticationRequired:e,playlists:t,devices:n,selectedDevice:i}=this.state;return e?H` + `}}const q=R.bind(o);class H extends E{constructor(e){super(e),this.dataRefreshToken=null,this.state={playlists:[],devices:[],selectedDevice:null,selectedPlaylist:null,currentPlayer:null,playingPlaylist:null,authenticationRequired:!0},this.scopes=["playlist-read-private","playlist-read-collaborative","user-read-playback-state","user-modify-playback-state"]}async componentDidMount(){document.addEventListener("visibilitychange",async()=>{document.hidden||(await this.checkAuthentication(),await this.refreshPlayData())}),await this.checkAuthentication(),await this.refreshPlayData(),this.dataRefreshToken=setInterval(async()=>{await this.refreshPlayData()},5e3)}componentWillUnmount(){clearInterval(this.dataRefreshToken)}getLocalStorageTokenName(){return"spotify-access_token-"+(this.props.account?this.props.account:"default")}async checkAuthentication(){const e=new URLSearchParams(window.location.hash.substring(1)),t=e.get("access_token")||localStorage.getItem(this.getLocalStorageTokenName()),n=localStorage.getItem(this.getLocalStorageTokenName()+"-expires_ms"),i={Authorization:`Bearer ${t}`},o=await fetch("https://api.spotify.com/v1/me",{headers:i}).then(e=>e.json());if(o.error)return 401===o.error.status?t&&0+n-(new Date).getTime()<0?this.authenticateSpotify():this.setState({authenticationRequired:!0}):(console.error("This should never happen:",response),this.setState({error:response.error}));if(e.get("access_token")){const n=e.get("expires_in");localStorage.setItem(this.getLocalStorageTokenName(),t),localStorage.setItem(this.getLocalStorageTokenName()+"-expires_ms",(new Date).getTime()+1e3*n);const i=window.location.href.split("#")[0];window.history.pushState({path:i},"",i)}this.setState({authenticationRequired:!1})}async refreshPlayData(){if(document.hidden)return;await this.checkAuthentication();const e={Authorization:`Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`};let t;t=this.props.featuredPlaylists?await fetch("https://api.spotify.com/v1/browse/featured-playlists?limit="+this.props.limit,{headers:e}).then(e=>e.json()).then(e=>e.playlists.items):await fetch("https://api.spotify.com/v1/me/playlists?limit="+this.props.limit,{headers:e}).then(e=>e.json()).then(e=>e.items);const n=await fetch("https://api.spotify.com/v1/me/player/devices",{headers:e}).then(e=>e.json()).then(e=>e.devices),i=await fetch("https://api.spotify.com/v1/me/player",{headers:e});let o,s=null,r=null;if(200===i.status&&(o=(r=await i.json()).device,r.context&&r.context.external_urls)){const e=r.context.external_urls.spotify;s=t.find(t=>e===t.external_urls.spotify)}this.setState({playlists:t,devices:n,selectedDevice:o,playingPlaylist:s,currentPlayer:r})}authenticateSpotify(){const e=window.location.href.split("?")[0],{clientId:t}=this.props;window.location.href=`https://accounts.spotify.com/authorize?client_id=${t}&redirect_uri=${e}&scope=${this.scopes.join("%20")}&response_type=token`}playPlaylist(){const{selectedPlaylist:e,selectedDevice:t}=this.state;e&&t?fetch("https://api.spotify.com/v1/me/player/play?device_id="+t.id,{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`},body:JSON.stringify({context_uri:e.uri})}).then(()=>this.setState({playingPlaylist:e})):console.error("Will not play because there is no playlist or device selected,",e,t)}onPlaylistSelect(e){this.setState({selectedPlaylist:e}),this.playPlaylist()}onMediaPlayerSelect(e){this.setState({selectedDevice:e}),this.state.currentPlayer&&fetch("https://api.spotify.com/v1/me/player",{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`},body:JSON.stringify({device_ids:[e.id],play:!0})})}onChromecastDeviceSelect(e){const t=this.state.playingPlaylist?this.state.playingPlaylist:this.state.playlists[0];if(!t)return void console.error("Nothing to play, skipping starting chromecast device");const n={device_name:e,uri:t.uri,transfer_playback:null!=this.state.currentPlayer};this.props.account&&(n.account=this.props.account),this.props.hass.callService("spotcast","start",n)}getHighlighted(e){const{selectedPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"highlight":""}getIsPlayingClass(e){const{playingPlaylist:t}=this.state,n=t?t.id:"";return e.id===n?"playing":""}render(){const{authenticationRequired:e,playlists:t,devices:n,selectedDevice:i}=this.state;if(e)return q`
<${V} />
- `:H` + `;const o={height:this.props.height?parseInt(this.props.height):"auto"};return q`
<${V} /> -
- ${t.map((e,t)=>{const n=e.images[0]?e.images[0].url:"https://via.placeholder.com/150x150.png?text=No+image";return H` +
+ ${t.map((e,t)=>{const n=e.images[0]?e.images[0].url:"https://via.placeholder.com/150x150.png?text=No+image";return q`
this.onPlaylistSelect(e,t,n,this)} @@ -55,7 +55,7 @@ `})}
- <${q} + <${j} devices=${n} selectedDevice=${i} hass=${this.props.hass} @@ -65,15 +65,18 @@ />
- `}}const V=()=>H` + `}}const V=()=>q`
-`,O=document.createElement("style"),F="rgb(30, 215, 96)",G="rgb(40, 40, 40)",J="rgb(24, 24, 24)",K="rgb(170, 170, 170)",Q="rgb(200, 200, 200)",X="rgb(255, 255, 255)";O.textContent=`\n .spotify_container {\n background-color: ${G};\n font-family: 'Roboto', sans-serif;\n color: ${X};\n font-size: 14px;\n padding: 25px;\n }\n .spotify_container *:focus {outline:none}\n .header img {\n height: 30px;\n margin-bottom: 10px;\n }\n .login__box {\n width: 100%;\n text-align: center;\n }\n .playlists {\n display: flex;\n flex-flow: column nowrap;\n margin-bottom: 15px;\n background-color: ${J};\n }\n .playlist {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n border-top: 1px solid ${G};\n height: 42px;\n }\n .playlist:active {\n background-color: rgb(200, 200, 240);\n }\n .playlist:last-child {\n border-bottom: 1px solid ${G};\n }\n .playlist:hover {\n background: ${G};\n cursor: pointer;\n }\n .highlight {\n background: ${G};\n }\n\n .playlist__cover_art img {\n width: 42px;\n height: 42px;\n }\n .playlist__number {\n margin-left: 10px;\n color: ${K};\n width: 12px;\n }\n\n .playlist__playicon {\n color: ${X};\n margin-left: 10px;\n }\n .playlist__playicon:hover {\n color: rgb(216, 255, 229);\n text-shadow: 0 0 20px rgb(216, 255, 229);\n }\n .playing {\n color: ${F}\n }\n\n .playlist__title {\n margin-left: 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 75vw;\n }\n .controls {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n }\n .greenButton {\n border-radius: 15px;\n padding: 0 20px 0 20px;\n font-size: 14px;\n height: 27px;\n color: white;\n border: none;\n background: ${F};\n cursor: pointer;\n margin-right: 10px;\n }\n .greenButton:hover {\n background-color: #43e57d;\n }\n .playButton::before {\n content: "\\25B6 "\n }\n\n .dropdown {\n position: relative;\n display: inline-block;\n color: ${Q};\n }\n .mediaplayer_select {\n display: flex;\n align-items: center;\n justify-content: center;\n }\n .mediaplayer_speaker_icon {\n display: inline-block;\n padding: 3px;\n width: 17px;\n height: 17px;\n margin-right: 10px;\n border: thin solid ${Q};\n border-radius: 50%;\n }\n .dropdown-content {\n display: none;\n position: absolute;\n background-color: ${G};\n min-width: 250px;\n box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);\n z-index: 1;\n }\n .dropdown-content a {\n color: ${Q};\n padding: 12px 16px;\n text-decoration: none;\n display: block;\n }\n .dropdown-content a:hover {\n box-shadow: inset 0 0 100px 100px rgba(255, 255, 255, 0.07);\n }\n .dropdown:hover .dropdown-content {\n display: block;\n }\n`;customElements.define("spotify-card",class extends HTMLElement{constructor(){super(),this.shadow=this.attachShadow({mode:"open"}),this.config={}}set hass(e){this.savedHass||(this.savedHass=e)}setConfig(e){if(!e.client_id)throw new Error("No client ---- id");this.config=e}getCardSize(){return 10}disconnectedCallback(){this.shadow.innerHTML=""}connectedCallback(){this.config.client_id||(this.config.client_id=this.getAttribute("client_id"));const e=document.createElement("div");var t,n;this.shadow.appendChild(O),this.shadow.appendChild(e),t=H` - <${j} +`,O=R.bind(o),F=document.createElement("style"),G="rgb(30, 215, 96)",J="rgb(40, 40, 40)",K="rgb(24, 24, 24)",Q="rgb(170, 170, 170)",X="rgb(200, 200, 200)",Y="rgb(255, 255, 255)";F.textContent=`\n .spotify_container {\n background-color: ${J};\n font-family: 'Roboto', sans-serif;\n color: ${Y};\n font-size: 14px;\n padding: 25px;\n }\n .spotify_container *:focus {outline:none}\n .header img {\n height: 30px;\n margin-bottom: 10px;\n }\n .login__box {\n width: 100%;\n text-align: center;\n }\n .playlists {\n display: flex;\n flex-flow: column nowrap;\n margin-bottom: 15px;\n background-color: ${K};\n overflow: scroll;\n }\n .playlist {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n border-top: 1px solid ${J};\n height: 42px;\n }\n .playlist:active {\n background-color: rgb(200, 200, 240);\n }\n .playlist:last-child {\n border-bottom: 1px solid ${J};\n }\n .playlist:hover {\n background: ${J};\n cursor: pointer;\n }\n .highlight {\n background: ${J};\n }\n\n .playlist__cover_art img {\n width: 42px;\n height: 42px;\n }\n .playlist__number {\n margin-left: 10px;\n color: ${Q};\n width: 12px;\n }\n\n .playlist__playicon {\n color: ${Y};\n margin-left: 10px;\n }\n .playlist__playicon:hover {\n color: rgb(216, 255, 229);\n text-shadow: 0 0 20px rgb(216, 255, 229);\n }\n .playing {\n color: ${G}\n }\n\n .playlist__title {\n margin-left: 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 75vw;\n }\n .controls {\n display: flex;\n flex-flow: row nowrap;\n align-items: center;\n }\n .greenButton {\n border-radius: 15px;\n padding: 0 20px 0 20px;\n font-size: 14px;\n height: 27px;\n color: white;\n border: none;\n background: ${G};\n cursor: pointer;\n margin-right: 10px;\n }\n .greenButton:hover {\n background-color: #43e57d;\n }\n .playButton::before {\n content: "\\25B6 "\n }\n\n .dropdown {\n position: relative;\n display: inline-block;\n color: ${X};\n }\n .mediaplayer_select {\n display: flex;\n align-items: center;\n justify-content: center;\n }\n .mediaplayer_speaker_icon {\n display: inline-block;\n padding: 3px;\n width: 17px;\n height: 17px;\n margin-right: 10px;\n border: thin solid ${X};\n border-radius: 50%;\n }\n .dropdown-content {\n display: none;\n position: absolute;\n background-color: ${J};\n min-width: 250px;\n box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);\n z-index: 1;\n }\n .dropdown-content a {\n color: ${X};\n padding: 12px 16px;\n text-decoration: none;\n display: block;\n }\n .dropdown-content a:hover {\n box-shadow: inset 0 0 100px 100px rgba(255, 255, 255, 0.07);\n }\n .dropdown:hover .dropdown-content {\n display: block;\n }\n`;customElements.define("spotify-card",class extends HTMLElement{constructor(){super(),this.shadow=this.attachShadow({mode:"open"}),this.config={}}set hass(e){this.savedHass||(this.savedHass=e)}setConfig(e){if(!e.client_id)throw new Error("No client ---- id");this.config=e}getCardSize(){return 10}disconnectedCallback(){this.shadow.innerHTML=""}connectedCallback(){this.config.client_id||(this.config.client_id=this.getAttribute("client_id"));const e=document.createElement("div");var t,n;this.shadow.appendChild(F),this.shadow.appendChild(e),t=O` + <${H} clientId=${this.config.client_id} limit=${this.config.limit||10} player=${this.config.device||"*"} hass=${this.savedHass} + featuredPlaylists=${this.config.featuredPlaylists||!1} + account=${this.config.account||""} + height=${this.config.height||""} /> - `,C(n,t,{},!1,e,!1)}})}); + `,k(n,t,{},!1,e,!1)}})}); diff --git a/package-lock.json b/package-lock.json index f39fb7d..b73a8fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "spotify-card", - "version": "1.7.0", + "version": "1.9.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6498323..39fee2a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spotify-card", "author": "Niklas Fondberg ", - "version": "1.9.0", + "version": "1.10.0", "browser": "dist/spotify-card.js", "dependencies": { "home-assistant-js-websocket": "^4.2.2", diff --git a/src/SpotifyCard.js b/src/SpotifyCard.js new file mode 100644 index 0000000..a304902 --- /dev/null +++ b/src/SpotifyCard.js @@ -0,0 +1,264 @@ +import { h, Component } from 'preact'; +import htm from 'htm'; +import PlayerSelect from './PlayerSelect'; + +const html = htm.bind(h); + + +export default class SpotifyCard extends Component { + constructor(props) { + super(props); + this.dataRefreshToken = null; + this.state = { + playlists: [], + devices: [], + selectedDevice: null, + selectedPlaylist: null, + currentPlayer: null, + playingPlaylist: null, + authenticationRequired: true, + }; + // playlist-read-collaborative + this.scopes = [ + 'playlist-read-private', + 'playlist-read-collaborative', + 'user-read-playback-state', + 'user-modify-playback-state', + ]; + } + + async componentDidMount() { + document.addEventListener('visibilitychange', async () => { + if (!document.hidden) { + await this.checkAuthentication(); + await this.refreshPlayData(); + } + }); + await this.checkAuthentication(); + await this.refreshPlayData(); + + this.dataRefreshToken = setInterval(async () => { + await this.refreshPlayData(); + }, 5000); // TODO: check if we can use the mp.spotify state instead? + } + + componentWillUnmount() { + clearInterval(this.dataRefreshToken); + } + + getLocalStorageTokenName() { + const account = this.props.account ? this.props.account : 'default'; + return 'spotify-access_token-' + account; + } + + async checkAuthentication() { + const hashParams = new URLSearchParams(window.location.hash.substring(1)); + const access_token = hashParams.get('access_token') || localStorage.getItem(this.getLocalStorageTokenName()); + const token_expires_ms = localStorage.getItem(this.getLocalStorageTokenName() + '-expires_ms'); + + const headers = { + Authorization: `Bearer ${access_token}`, + }; + + const userResp = await fetch('https://api.spotify.com/v1/me', { headers }).then(r => r.json()); + + if (userResp.error) { + if (userResp.error.status === 401) { + // Have a token but it is old + if (access_token && 0 + token_expires_ms - new Date().getTime() < 0) { + return this.authenticateSpotify(); + } + // no token - show login button + return this.setState({ authenticationRequired: true }); + } + console.error('This should never happen:', response); + return this.setState({ error: response.error }); + } + + if (hashParams.get('access_token')) { + const expires_in = hashParams.get('expires_in'); + localStorage.setItem(this.getLocalStorageTokenName(), access_token); + localStorage.setItem(this.getLocalStorageTokenName() + '-expires_ms', new Date().getTime() + expires_in * 1000); + + // Auth success, remove the parameters from spotify + const newurl = window.location.href.split('#')[0]; + window.history.pushState({ path: newurl }, '', newurl); + } + + this.setState({ authenticationRequired: false }); + } + + async refreshPlayData() { + if (document.hidden) { + return; + } + await this.checkAuthentication(); + const headers = { + Authorization: `Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`, + }; + + let playlists; + if (this.props.featuredPlaylists) { + playlists = await fetch('https://api.spotify.com/v1/browse/featured-playlists?limit=' + this.props.limit, { headers }) + .then(r => r.json()) + .then(r => r.playlists.items); + } else { + 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); + + const currentPlayerRes = await fetch('https://api.spotify.com/v1/me/player', { headers }); + + let selectedDevice, + playingPlaylist = null, + currentPlayer = null; + // 200 is returned when something is playing. 204 is ok status without body. + if (currentPlayerRes.status === 200) { + currentPlayer = await currentPlayerRes.json(); + // console.log('Currently playing:', currentPlayer); + selectedDevice = currentPlayer.device; + if (currentPlayer.context && currentPlayer.context.external_urls) { + const currPlayingHref = currentPlayer.context.external_urls.spotify; + playingPlaylist = playlists.find(pl => currPlayingHref === pl.external_urls.spotify); + } + } + + this.setState({ playlists, devices, selectedDevice, playingPlaylist, currentPlayer }); + } + + authenticateSpotify() { + const redirectUrl = window.location.href.split('?')[0]; + const { clientId } = this.props; + window.location.href = `https://accounts.spotify.com/authorize?client_id=${clientId}&redirect_uri=${redirectUrl}&scope=${this.scopes.join( + '%20' + )}&response_type=token`; + } + + playPlaylist() { + const { selectedPlaylist, selectedDevice } = this.state; + if (!selectedPlaylist || !selectedDevice) { + console.error('Will not play because there is no playlist or device selected,', selectedPlaylist, selectedDevice); + return; + } + fetch('https://api.spotify.com/v1/me/player/play?device_id=' + selectedDevice.id, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`, + }, + body: JSON.stringify({ context_uri: selectedPlaylist.uri }), + }).then(() => this.setState({ playingPlaylist: selectedPlaylist })); + } + + onPlaylistSelect(playlist) { + this.setState({ selectedPlaylist: playlist }); + this.playPlaylist(); + } + + onMediaPlayerSelect(device) { + this.setState({ selectedDevice: device }); + + if (this.state.currentPlayer) { + fetch('https://api.spotify.com/v1/me/player', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem(this.getLocalStorageTokenName())}`, + }, + body: JSON.stringify({ device_ids: [device.id], play: true }), + }); + } + } + + 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; + } + const options = { + device_name: device, + uri: playlist.uri, + transfer_playback: this.state.currentPlayer != null, + }; + + if (this.props.account) { + options.account = this.props.account; + } + + this.props.hass.callService('spotcast', 'start', options); + } + + getHighlighted(playlist) { + const { selectedPlaylist } = this.state; + const selectedPlaylistId = selectedPlaylist ? selectedPlaylist.id : ''; + return playlist.id === selectedPlaylistId ? 'highlight' : ''; + } + + getIsPlayingClass(playlist) { + const { playingPlaylist } = this.state; + const playingPlaylistId = playingPlaylist ? playingPlaylist.id : ''; + return playlist.id === playingPlaylistId ? 'playing' : ''; + } + + render() { + const { authenticationRequired, playlists, devices, selectedDevice } = this.state; + if (authenticationRequired) { + return html` +
+ <${Header} /> + +
+ `; + } + + const playlistStyle = { height: this.props.height ? parseInt(this.props.height) : 'auto' }; + + return html` +
+ <${Header} /> +
+ ${playlists.map((playlist, idx) => { + const image = playlist.images[0] + ? playlist.images[0].url + : 'https://via.placeholder.com/150x150.png?text=No+image'; + return html` +
this.onPlaylistSelect(playlist, idx, event, this)} + > +
+
${idx + 1}
+
+
${playlist.name}
+
+ `; + })} +
+
+ <${PlayerSelect} + devices=${devices} + selectedDevice=${selectedDevice} + hass=${this.props.hass} + player=${this.props.player} + onMediaplayerSelect=${device => this.onMediaPlayerSelect(device)} + onChromecastDeviceSelect=${device => this.onChromecastDeviceSelect(device)} + /> +
+
+ `; + } +} + +const Header = () => html` +
+ +
+`; diff --git a/src/spotify-card.js b/src/spotify-card.js index 2308181..850f0a8 100644 --- a/src/spotify-card.js +++ b/src/spotify-card.js @@ -17,242 +17,8 @@ import 'core-js/stable'; import { h, Component, render } from 'preact'; import htm from 'htm'; - -import PlayerSelect from './PlayerSelect'; - const html = htm.bind(h); - -class SpotifyCard extends Component { - constructor(props) { - super(props); - this.dataRefreshToken = null; - this.state = { - playlists: [], - devices: [], - selectedDevice: null, - selectedPlaylist: null, - currentPlayer: null, - playingPlaylist: null, - authenticationRequired: true, - }; - this.scopes = ['playlist-read-private', 'user-read-playback-state', 'user-modify-playback-state']; - } - - async componentDidMount() { - document.addEventListener('visibilitychange', async () => { - if (!document.hidden) { - await this.checkAuthentication(); - await this.refreshPlayData(); - } - }); - await this.checkAuthentication(); - await this.refreshPlayData(); - - this.dataRefreshToken = setInterval(async () => { - await this.refreshPlayData(); - }, 5000); // TODO: check if we can use the mp.spotify state instead? - } - - componentWillUnmount() { - clearInterval(this.dataRefreshToken); - } - - async checkAuthentication() { - const hashParams = new URLSearchParams(window.location.hash.substring(1)); - const access_token = hashParams.get('access_token') || localStorage.getItem('access_token'); - const token_expires_ms = localStorage.getItem('token_expires_ms'); - - const headers = { - Authorization: `Bearer ${access_token}`, - }; - - const userResp = await fetch('https://api.spotify.com/v1/me', { headers }).then(r => r.json()); - - if (userResp.error) { - if (userResp.error.status === 401) { - // Have a token but it is old - if (access_token && 0 + token_expires_ms - new Date().getTime() < 0) { - return this.authenticateSpotify(); - } - // no token - show login button - return this.setState({ authenticationRequired: true }); - } - console.error('This should never happen:', response); - return this.setState({ error: response.error }); - } - - if (hashParams.get('access_token')) { - const expires_in = hashParams.get('expires_in'); - localStorage.setItem('access_token', access_token); - localStorage.setItem('token_expires_ms', new Date().getTime() + expires_in * 1000); - - // Auth success, remove the parameters from spotify - const newurl = window.location.href.split('#')[0]; - window.history.pushState({ path: newurl }, '', newurl); - } - - this.setState({ authenticationRequired: false }); - } - - async refreshPlayData() { - if (document.hidden) { - return; - } - await this.checkAuthentication(); - 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); - - const currentPlayerRes = await fetch('https://api.spotify.com/v1/me/player', { headers }); - - let selectedDevice, - playingPlaylist = null, - currentPlayer = null; - // 200 is returned when something is playing. 204 is ok status without body. - if (currentPlayerRes.status === 200) { - currentPlayer = await currentPlayerRes.json(); - // console.log('Currently playing:', currentPlayer); - selectedDevice = currentPlayer.device; - if (currentPlayer.context && currentPlayer.context.external_urls) { - const currPlayingHref = currentPlayer.context.external_urls.spotify; - playingPlaylist = playlists.find(pl => currPlayingHref === pl.external_urls.spotify); - } - } - - this.setState({ playlists, devices, selectedDevice, playingPlaylist, currentPlayer }); - } - - authenticateSpotify() { - const redirectUrl = window.location.href.split('?')[0]; - const { clientId } = this.props; - window.location.href = `https://accounts.spotify.com/authorize?client_id=${clientId}&redirect_uri=${redirectUrl}&scope=${this.scopes.join( - '%20' - )}&response_type=token`; - } - - playPlaylist() { - const { selectedPlaylist, selectedDevice } = this.state; - if (!selectedPlaylist || !selectedDevice) { - console.error('Will not play because there is no playlist or device selected,', selectedPlaylist, selectedDevice); - return; - } - fetch('https://api.spotify.com/v1/me/player/play?device_id=' + selectedDevice.id, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem('access_token')}`, - }, - body: JSON.stringify({ context_uri: selectedPlaylist.uri }), - }).then(() => this.setState({ playingPlaylist: selectedPlaylist })); - } - - onPlaylistSelect(playlist) { - this.setState({ selectedPlaylist: playlist }); - this.playPlaylist(); - } - - onMediaPlayerSelect(device) { - this.setState({ selectedDevice: device }); - - if (this.state.currentPlayer) { - fetch('https://api.spotify.com/v1/me/player', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem('access_token')}`, - }, - body: JSON.stringify({ device_ids: [device.id], play: true }), - }); - } - } - - 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; - } - - this.props.hass.callService('spotcast', 'start', { - device_name: device, - uri: playlist.uri, - transfer_playback: this.state.currentPlayer != null, - }); - } - - getHighlighted(playlist) { - const { selectedPlaylist } = this.state; - const selectedPlaylistId = selectedPlaylist ? selectedPlaylist.id : ''; - return playlist.id === selectedPlaylistId ? 'highlight' : ''; - } - - getIsPlayingClass(playlist) { - const { playingPlaylist } = this.state; - const playingPlaylistId = playingPlaylist ? playingPlaylist.id : ''; - return playlist.id === playingPlaylistId ? 'playing' : ''; - } - - render() { - const { authenticationRequired, playlists, devices, selectedDevice } = this.state; - if (authenticationRequired) { - return html` -
- <${Header} /> - -
- `; - } - - return html` -
- <${Header} /> -
- ${playlists.map((playlist, idx) => { - const image = playlist.images[0] - ? playlist.images[0].url - : 'https://via.placeholder.com/150x150.png?text=No+image'; - return html` -
this.onPlaylistSelect(playlist, idx, event, this)} - > -
-
${idx + 1}
-
-
${playlist.name}
-
- `; - })} -
-
- <${PlayerSelect} - devices=${devices} - selectedDevice=${selectedDevice} - hass=${this.props.hass} - player=${this.props.player} - onMediaplayerSelect=${device => this.onMediaPlayerSelect(device)} - onChromecastDeviceSelect=${device => this.onChromecastDeviceSelect(device)} - /> -
-
- `; - } -} - -const Header = () => html` -
- -
-`; +import SpotifyCard from './SpotifyCard'; const styleElement = document.createElement('style'); const styles = { @@ -287,6 +53,7 @@ styleElement.textContent = ` flex-flow: column nowrap; margin-bottom: 15px; background-color: ${styles.black}; + overflow: scroll; } .playlist { display: flex; @@ -445,6 +212,9 @@ class SpotifyCardWebComponent extends HTMLElement { limit=${this.config.limit || 10} player=${this.config.device || '*'} hass=${this.savedHass} + featuredPlaylists=${this.config.featuredPlaylists || false} + account=${this.config.account || ''} + height=${this.config.height || ''} /> `, mountPoint