From 6d961244fb80358aecd46e0faae1cbb128393515 Mon Sep 17 00:00:00 2001 From: Niklas Fondberg Date: Sat, 3 Aug 2019 16:20:31 +0200 Subject: [PATCH] Add HACS support (#18) --- README.md | 17 +- dist/spotify-card.cjs.js | 617 --------------------------------------- dist/spotify-card.esm.js | 613 -------------------------------------- dist/spotify-card.js | 79 +++++ package.json | 8 +- rollup.config.js | 17 -- 6 files changed, 90 insertions(+), 1261 deletions(-) delete mode 100644 dist/spotify-card.cjs.js delete mode 100644 dist/spotify-card.esm.js create mode 100644 dist/spotify-card.js diff --git a/README.md b/README.md index 8f190a9..e679557 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ Removed need for custom sensor from [My Spotify Chromecast custom component](htt 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). + ![Screenshot](/spotify-card-highlight.png) ### Requirements @@ -39,21 +42,17 @@ For more information about how to create an app see [Home Assistant Spotify Comp Add the resource in lovelace config: -##### Latest release: -``` - - type: module - url: >- - https://cdn.jsdelivr.net/gh/custom-cards/spotify-card@1.8/dist/spotify-card.umd.js -``` +##### HACS users: +Follow the configuration the instructions when installing it. + -##### master version: +##### Latest release using cdn: ``` - type: module url: >- - https://cdn.jsdelivr.net/gh/custom-cards/spotify-card/dist/spotify-card.umd.js + https://cdn.jsdelivr.net/gh/custom-cards/spotify-card@1.8/dist/spotify-card.umd.js ``` - ##### Add the card to lovelace config Now add the card like this: ``` diff --git a/dist/spotify-card.cjs.js b/dist/spotify-card.cjs.js deleted file mode 100644 index c45ed0f..0000000 --- a/dist/spotify-card.cjs.js +++ /dev/null @@ -1,617 +0,0 @@ -'use strict'; - -function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } - -require('core-js/stable'); -var preact = require('preact'); -var htm = _interopDefault(require('htm')); - -const html = htm.bind(preact.h); -class PlayerSelect extends preact.Component { - constructor() { - super(); - this.state = { - selectedDevice: '-- choose mediaplayer --', - castEntities: [] - }; - } - - componentWillUnmount() { - typeof this.unsubscribeEntitites === 'function' && this.unsubscribeEntitites(); - } - - async componentDidMount() { - await this.refreshCastEntities(); - this.dataRefreshToken = setInterval(async () => { - await this.refreshCastEntities(); - }, 5000); // TODO: check if we can use the mp.spotify state instead? - } - - async refreshCastEntities() { - const res = await this.props.hass.callWS({ - type: 'config/entity_registry/list' - }); - const castEntities = res.filter(e => e.platform == 'cast').map(e => this.props.hass.states[e.entity_id]).filter(e => e != null); - this.setState({ - castEntities - }); - } - - componentWillUnmount() { - clearInterval(this.dataRefreshToken); - } - - componentWillReceiveProps(props, state) { - if (props.selectedDevice) { - this.setState({ - selectedDevice: props.selectedDevice.name - }); - } - } - - selectDevice(device) { - this.setState({ - selectedDevice: device.name - }); - this.props.onMediaplayerSelect(device); - } - - selectChromecastDevice(device) { - this.props.onChromecastDeviceSelect(device); - } - - render() { - const { - devices - } = this.props; - const { - castEntities - } = this.state; - const choice_form = html` - - `; - let form = ''; - - if (this.props.player && this.props.player == this.state.selectedDevice) { - form = ''; // We have selected the player already - } else if (this.props.player && this.props.player != this.state.selectedDevice) { - const selected = devices.filter(d => d.name == this.props.player); - - if (selected.length == 1) { - form = ''; - this.selectDevice(selected[0]); - } else { - // console.log(`Was not able to find player ${this.props.player} within ${JSON.stringify(devices)}`); - form = choice_form; - } - } else { - form = choice_form; - } - - return html` - - `; - } - -} - -/** - * @license - * Copyright 2019 Niklas Fondberg. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -const html$1 = htm.bind(preact.h); - -class SpotifyCard extends preact.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$1` -
- <${Header} /> - -
- `; - } - - return html$1` -
- <${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$1` -
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$1` -
- -
-`; - -const styleElement = document.createElement('style'); -const styles = { - green: 'rgb(30, 215, 96)', - lightBlack: 'rgb(40, 40, 40)', - black: 'rgb(24, 24, 24)', - grey: 'rgb(170, 170, 170)', - sand: 'rgb(200, 200, 200)', - white: 'rgb(255, 255, 255)', - blue: '#4688d7' -}; -styleElement.textContent = ` - .spotify_container { - background-color: ${styles.lightBlack}; - font-family: 'Roboto', sans-serif; - color: ${styles.white}; - font-size: 14px; - padding: 25px; - } - .spotify_container *:focus {outline:none} - .header img { - height: 30px; - margin-bottom: 10px; - } - .login__box { - width: 100%; - text-align: center; - } - .playlists { - display: flex; - flex-flow: column nowrap; - margin-bottom: 15px; - background-color: ${styles.black}; - } - .playlist { - display: flex; - flex-flow: row nowrap; - align-items: center; - border-top: 1px solid ${styles.lightBlack}; - height: 42px; - } - .playlist:active { - background-color: rgb(200, 200, 240); - } - .playlist:last-child { - border-bottom: 1px solid ${styles.lightBlack}; - } - .playlist:hover { - background: ${styles.lightBlack}; - cursor: pointer; - } - .highlight { - background: ${styles.lightBlack}; - } - - .playlist__cover_art img { - width: 42px; - height: 42px; - } - .playlist__number { - margin-left: 10px; - color: ${styles.grey}; - width: 12px; - } - - .playlist__playicon { - color: ${styles.white}; - margin-left: 10px; - } - .playlist__playicon:hover { - color: rgb(216, 255, 229); - text-shadow: 0 0 20px rgb(216, 255, 229); - } - .playing { - color: ${styles.green} - } - - .playlist__title { - margin-left: 30px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 75vw; - } - .controls { - display: flex; - flex-flow: row nowrap; - align-items: center; - } - .greenButton { - border-radius: 15px; - padding: 0 20px 0 20px; - font-size: 14px; - height: 27px; - color: white; - border: none; - background: ${styles.green}; - cursor: pointer; - margin-right: 10px; - } - .greenButton:hover { - background-color: #43e57d; - } - .playButton::before { - content: "\\25B6 " - } - - .dropdown { - position: relative; - display: inline-block; - color: ${styles.sand}; - } - .mediaplayer_select { - display: flex; - align-items: center; - justify-content: center; - } - .mediaplayer_speaker_icon { - display: inline-block; - padding: 3px; - width: 17px; - height: 17px; - margin-right: 10px; - border: thin solid ${styles.sand}; - border-radius: 50%; - } - .dropdown-content { - display: none; - position: absolute; - background-color: ${styles.lightBlack}; - min-width: 250px; - box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); - z-index: 1; - } - .dropdown-content a { - color: ${styles.sand}; - padding: 12px 16px; - text-decoration: none; - display: block; - } - .dropdown-content a:hover { - box-shadow: inset 0 0 100px 100px rgba(255, 255, 255, 0.07); - } - .dropdown:hover .dropdown-content { - display: block; - } -`; - -class SpotifyCardWebComponent extends HTMLElement { - constructor() { - super(); - this.shadow = this.attachShadow({ - mode: 'open' - }); - this.config = {}; - } - - set hass(hass) { - // console.log('HASS:', hass); - if (!this.savedHass) { - this.savedHass = hass; - } - } - - setConfig(config) { - if (!config.client_id) { - throw new Error('No client ---- id'); - } - - this.config = config; - } - - getCardSize() { - return 10; - } - - disconnectedCallback() { - this.shadow.innerHTML = ''; - } - - connectedCallback() { - if (!this.config.client_id) { - this.config.client_id = this.getAttribute('client_id'); - } - - const mountPoint = document.createElement('div'); - this.shadow.appendChild(styleElement); - this.shadow.appendChild(mountPoint); - preact.render(html$1` - <${SpotifyCard} - clientId=${this.config.client_id} - limit=${this.config.limit || 10} - player=${this.config.device || '*'} - hass=${this.savedHass} - /> - `, mountPoint); - } - -} - -customElements.define('spotify-card', SpotifyCardWebComponent); diff --git a/dist/spotify-card.esm.js b/dist/spotify-card.esm.js deleted file mode 100644 index 7869b18..0000000 --- a/dist/spotify-card.esm.js +++ /dev/null @@ -1,613 +0,0 @@ -import 'core-js/stable'; -import { h, Component, render } from 'preact'; -import htm from 'htm'; - -const html = htm.bind(h); -class PlayerSelect extends Component { - constructor() { - super(); - this.state = { - selectedDevice: '-- choose mediaplayer --', - castEntities: [] - }; - } - - componentWillUnmount() { - typeof this.unsubscribeEntitites === 'function' && this.unsubscribeEntitites(); - } - - async componentDidMount() { - await this.refreshCastEntities(); - this.dataRefreshToken = setInterval(async () => { - await this.refreshCastEntities(); - }, 5000); // TODO: check if we can use the mp.spotify state instead? - } - - async refreshCastEntities() { - const res = await this.props.hass.callWS({ - type: 'config/entity_registry/list' - }); - const castEntities = res.filter(e => e.platform == 'cast').map(e => this.props.hass.states[e.entity_id]).filter(e => e != null); - this.setState({ - castEntities - }); - } - - componentWillUnmount() { - clearInterval(this.dataRefreshToken); - } - - componentWillReceiveProps(props, state) { - if (props.selectedDevice) { - this.setState({ - selectedDevice: props.selectedDevice.name - }); - } - } - - selectDevice(device) { - this.setState({ - selectedDevice: device.name - }); - this.props.onMediaplayerSelect(device); - } - - selectChromecastDevice(device) { - this.props.onChromecastDeviceSelect(device); - } - - render() { - const { - devices - } = this.props; - const { - castEntities - } = this.state; - const choice_form = html` - - `; - let form = ''; - - if (this.props.player && this.props.player == this.state.selectedDevice) { - form = ''; // We have selected the player already - } else if (this.props.player && this.props.player != this.state.selectedDevice) { - const selected = devices.filter(d => d.name == this.props.player); - - if (selected.length == 1) { - form = ''; - this.selectDevice(selected[0]); - } else { - // console.log(`Was not able to find player ${this.props.player} within ${JSON.stringify(devices)}`); - form = choice_form; - } - } else { - form = choice_form; - } - - return html` - - `; - } - -} - -/** - * @license - * Copyright 2019 Niklas Fondberg. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -const html$1 = 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$1` -
- <${Header} /> - -
- `; - } - - return html$1` -
- <${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$1` -
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$1` -
- -
-`; - -const styleElement = document.createElement('style'); -const styles = { - green: 'rgb(30, 215, 96)', - lightBlack: 'rgb(40, 40, 40)', - black: 'rgb(24, 24, 24)', - grey: 'rgb(170, 170, 170)', - sand: 'rgb(200, 200, 200)', - white: 'rgb(255, 255, 255)', - blue: '#4688d7' -}; -styleElement.textContent = ` - .spotify_container { - background-color: ${styles.lightBlack}; - font-family: 'Roboto', sans-serif; - color: ${styles.white}; - font-size: 14px; - padding: 25px; - } - .spotify_container *:focus {outline:none} - .header img { - height: 30px; - margin-bottom: 10px; - } - .login__box { - width: 100%; - text-align: center; - } - .playlists { - display: flex; - flex-flow: column nowrap; - margin-bottom: 15px; - background-color: ${styles.black}; - } - .playlist { - display: flex; - flex-flow: row nowrap; - align-items: center; - border-top: 1px solid ${styles.lightBlack}; - height: 42px; - } - .playlist:active { - background-color: rgb(200, 200, 240); - } - .playlist:last-child { - border-bottom: 1px solid ${styles.lightBlack}; - } - .playlist:hover { - background: ${styles.lightBlack}; - cursor: pointer; - } - .highlight { - background: ${styles.lightBlack}; - } - - .playlist__cover_art img { - width: 42px; - height: 42px; - } - .playlist__number { - margin-left: 10px; - color: ${styles.grey}; - width: 12px; - } - - .playlist__playicon { - color: ${styles.white}; - margin-left: 10px; - } - .playlist__playicon:hover { - color: rgb(216, 255, 229); - text-shadow: 0 0 20px rgb(216, 255, 229); - } - .playing { - color: ${styles.green} - } - - .playlist__title { - margin-left: 30px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 75vw; - } - .controls { - display: flex; - flex-flow: row nowrap; - align-items: center; - } - .greenButton { - border-radius: 15px; - padding: 0 20px 0 20px; - font-size: 14px; - height: 27px; - color: white; - border: none; - background: ${styles.green}; - cursor: pointer; - margin-right: 10px; - } - .greenButton:hover { - background-color: #43e57d; - } - .playButton::before { - content: "\\25B6 " - } - - .dropdown { - position: relative; - display: inline-block; - color: ${styles.sand}; - } - .mediaplayer_select { - display: flex; - align-items: center; - justify-content: center; - } - .mediaplayer_speaker_icon { - display: inline-block; - padding: 3px; - width: 17px; - height: 17px; - margin-right: 10px; - border: thin solid ${styles.sand}; - border-radius: 50%; - } - .dropdown-content { - display: none; - position: absolute; - background-color: ${styles.lightBlack}; - min-width: 250px; - box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); - z-index: 1; - } - .dropdown-content a { - color: ${styles.sand}; - padding: 12px 16px; - text-decoration: none; - display: block; - } - .dropdown-content a:hover { - box-shadow: inset 0 0 100px 100px rgba(255, 255, 255, 0.07); - } - .dropdown:hover .dropdown-content { - display: block; - } -`; - -class SpotifyCardWebComponent extends HTMLElement { - constructor() { - super(); - this.shadow = this.attachShadow({ - mode: 'open' - }); - this.config = {}; - } - - set hass(hass) { - // console.log('HASS:', hass); - if (!this.savedHass) { - this.savedHass = hass; - } - } - - setConfig(config) { - if (!config.client_id) { - throw new Error('No client ---- id'); - } - - this.config = config; - } - - getCardSize() { - return 10; - } - - disconnectedCallback() { - this.shadow.innerHTML = ''; - } - - connectedCallback() { - if (!this.config.client_id) { - this.config.client_id = this.getAttribute('client_id'); - } - - const mountPoint = document.createElement('div'); - this.shadow.appendChild(styleElement); - this.shadow.appendChild(mountPoint); - render(html$1` - <${SpotifyCard} - clientId=${this.config.client_id} - limit=${this.config.limit || 10} - player=${this.config.device || '*'} - hass=${this.savedHass} - /> - `, mountPoint); - } - -} - -customElements.define('spotify-card', SpotifyCardWebComponent); diff --git a/dist/spotify-card.js b/dist/spotify-card.js new file mode 100644 index 0000000..357f27f --- /dev/null +++ b/dist/spotify-card.js @@ -0,0 +1,79 @@ +!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` + + `;let i="";if(this.props.player&&this.props.player==this.state.selectedDevice)i="";else if(this.props.player&&this.props.player!=this.state.selectedDevice){const t=e.filter(e=>e.name==this.props.player);1==t.length?(i="",this.selectDevice(t[0])):i=n}else i=n;return W` + + `}}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` +
+ <${V} /> + +
+ `:H` +
+ <${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` +
this.onPlaylistSelect(e,t,n,this)} + > +
+
${t+1}
+
+
${e.name}
+
+ `})} +
+
+ <${q} + devices=${n} + selectedDevice=${i} + hass=${this.props.hass} + player=${this.props.player} + onMediaplayerSelect=${e=>this.onMediaPlayerSelect(e)} + onChromecastDeviceSelect=${e=>this.onChromecastDeviceSelect(e)} + /> +
+
+ `}}const V=()=>H` +
+ +
+`,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} + clientId=${this.config.client_id} + limit=${this.config.limit||10} + player=${this.config.device||"*"} + hass=${this.savedHass} + /> + `,C(n,t,{},!1,e,!1)}})}); diff --git a/package.json b/package.json index 7a2a3b8..31bed8c 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,7 @@ { "name": "spotify-card", - "version": "1.8.0", - "main": "dist/spotify-card.cjs.js", - "module": "dist/spotify-card.esm.js", - "browser": "dist/spotify-card.umd.js", + "version": "1.9.0", + "browser": "dist/spotify-card.js", "dependencies": { "home-assistant-js-websocket": "^4.2.2", "htm": "^2.1.1", @@ -23,7 +21,7 @@ "rollup-plugin-terser": "^5.1.1" }, "scripts": { - "build": "rollup -c", + "build": "rollup -c && cp dist/spotify-card.js dist/spotify-card.umd.js", "dev": "rollup -c -w", "serve": "live-server --ignore=node_modules --port=50994 ./", "test": "echo 'tests' && exit 0" diff --git a/rollup.config.js b/rollup.config.js index 5accbf9..d541da4 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -25,21 +25,4 @@ export default [ terser(), ], }, - - // CommonJS (for Node) and ES module (for bundlers) build. - // (We could have three entries in the configuration array - // instead of two, but it's quicker to generate multiple - // builds from a single configuration where possible, using - // an array for the `output` option, where we can specify - // `file` and `format` for each target) - { - input: 'src/spotify-card.js', - external: ['ms'], - output: [{ file: pkg.main, format: 'cjs' }, { file: pkg.module, format: 'es' }], - plugins: [ - babel({ - exclude: ['node_modules/**'], - }), - ], - }, ];