Skip to content

Commit

Permalink
Merge pull request #33 from custom-cards/multi-account-support
Browse files Browse the repository at this point in the history
Add multi account support
  • Loading branch information
fondberg authored Dec 18, 2019
2 parents dbbda9c + 3eaf67a commit bc6c3aa
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 259 deletions.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -65,6 +70,9 @@ Now add the card like this:
client_id: <YOUR CLIENT ID>
limit: <optional number of playlists to retrieve (default 10)>
device: <optional name of a device to pre-select>
player: <optional use this player only, value should be the same name as the displayname of the player>
featuredPlaylists: <optional show featured playlists instead of users playlists>
height: <optional pixels height for the playlist element. If content is larger scrolling will be enabled>
```

If you add the `device` setting, the card will select it by default and will not display the dropdown menu.
Expand Down
23 changes: 13 additions & 10 deletions dist/spotify-card.js

Large diffs are not rendered by default.

23 changes: 13 additions & 10 deletions dist/spotify-card.umd.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "spotify-card",
"author": "Niklas Fondberg <[email protected]>",
"version": "1.9.0",
"version": "1.10.0",
"browser": "dist/spotify-card.js",
"dependencies": {
"home-assistant-js-websocket": "^4.2.2",
Expand Down
264 changes: 264 additions & 0 deletions src/SpotifyCard.js
Original file line number Diff line number Diff line change
@@ -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`
<div class="spotify_container">
<${Header} />
<div class="login__box">
<button class="greenButton" onClick=${() => this.authenticateSpotify()}>AUTHENTICATE</button>
</div>
</div>
`;
}

const playlistStyle = { height: this.props.height ? parseInt(this.props.height) : 'auto' };

return html`
<div class="spotify_container">
<${Header} />
<div class="playlists" style=${playlistStyle}>
${playlists.map((playlist, idx) => {
const image = playlist.images[0]
? playlist.images[0].url
: 'https://via.placeholder.com/150x150.png?text=No+image';
return html`
<div
class="${`playlist ${this.getHighlighted(playlist)}`}"
onClick=${event => this.onPlaylistSelect(playlist, idx, event, this)}
>
<div class="playlist__cover_art"><img src="${image}" /></div>
<div class="playlist__number">${idx + 1}</div>
<div class="${`playlist__playicon ${this.getIsPlayingClass(playlist)}`}"></div>
<div class="playlist__title">${playlist.name}</div>
</div>
`;
})}
</div>
<div class="controls">
<${PlayerSelect}
devices=${devices}
selectedDevice=${selectedDevice}
hass=${this.props.hass}
player=${this.props.player}
onMediaplayerSelect=${device => this.onMediaPlayerSelect(device)}
onChromecastDeviceSelect=${device => this.onChromecastDeviceSelect(device)}
/>
</div>
</div>
`;
}
}

const Header = () => html`
<div class="header">
<img src="https://storage.googleapis.com/pr-newsroom-wp/1/2018/11/Spotify_Logo_RGB_White.png" />
</div>
`;
Loading

0 comments on commit bc6c3aa

Please sign in to comment.