About two months or so ago I added a Now page to my site. It shows my current reading list, my last watched movies, my Untappd beer check-ins, and my most recent Spotify tracks. You can see that part here:
When I built it, I used a Pipedream workflow to wrap calls to Spotify's API. My Pipedream workflow gets my most recent tracks, slims down the data quite a bit, and returns just what I need. I use some client-side code to hit that endpoint and then render it out on the Now page. (I also use a bit of caching with LocalStorage such that the endpoint is only hit every ten minutes.)
Currently, when rendering each track, I link to its URL and Spotify users can listen to the track completely. I thought it would be cool to let people preview the tracks right from the web page. Here's how I did that.
Updating the "Back End"
In my case, my back end is just the Pipedream workflow. As I mentioned, it hits the Spotify API and then transforms the data into something smaller before returning it. All I had to do was update that one step:
export default defineComponent({
async run({ steps, $ }) {
return steps.get_recent_tracks.$return_value.items.map(r => {
return {
artists: r.track.artists,
name: r.track.name,
href: r.track.external_urls.spotify,
preview_url: r.track.preview_url,
album: r.track.album.name,
album_release_date: r.track.album.release_date,
images: r.track.album.images,
played_at:r.played_at
}
})
},
})
To be clear, this isn't strictly necessary, I could simply return everything Spotify sends, but as it is sending a lot I don't need, this small step really improves the performance of my API. As an example, here's one result from Spotify (this is in an array of results):
{
"track": {
"album": {
"album_type": "album",
"artists": [
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5HYNPEO2NNBONQkp3Mvwvc"
},
"href": "https://api.spotify.com/v1/artists/5HYNPEO2NNBONQkp3Mvwvc",
"id": "5HYNPEO2NNBONQkp3Mvwvc",
"name": "Scott Bradlee's Postmodern Jukebox",
"type": "artist",
"uri": "spotify:artist:5HYNPEO2NNBONQkp3Mvwvc"
}
],
"available_markets": [
"AR",
"AU",
"AT",
"BE",
"BO",
"BR",
"BG",
"CA",
"CL",
"CO",
"CR",
"CY",
"CZ",
"DK",
"DO",
"DE",
"EC",
"EE",
"SV",
"FI",
"FR",
"GR",
"GT",
"HN",
"HK",
"HU",
"IS",
"IE",
"IT",
"LV",
"LT",
"LU",
"MY",
"MT",
"MX",
"NL",
"NZ",
"NI",
"NO",
"PA",
"PY",
"PE",
"PH",
"PL",
"PT",
"SG",
"SK",
"ES",
"SE",
"CH",
"TW",
"TR",
"UY",
"US",
"GB",
"AD",
"LI",
"MC",
"ID",
"JP",
"TH",
"VN",
"RO",
"IL",
"ZA",
"SA",
"AE",
"BH",
"QA",
"OM",
"KW",
"EG",
"MA",
"DZ",
"TN",
"LB",
"JO",
"PS",
"IN",
"BY",
"KZ",
"MD",
"UA",
"AL",
"BA",
"HR",
"ME",
"MK",
"RS",
"SI",
"KR",
"BD",
"PK",
"LK",
"GH",
"KE",
"NG",
"TZ",
"UG",
"AG",
"AM",
"BS",
"BB",
"BZ",
"BT",
"BW",
"BF",
"CV",
"CW",
"DM",
"FJ",
"GM",
"GE",
"GD",
"GW",
"GY",
"HT",
"JM",
"KI",
"LS",
"LR",
"MW",
"MV",
"ML",
"MH",
"FM",
"NA",
"NR",
"NE",
"PW",
"PG",
"WS",
"SM",
"ST",
"SN",
"SC",
"SL",
"SB",
"KN",
"LC",
"VC",
"SR",
"TL",
"TO",
"TT",
"TV",
"VU",
"AZ",
"BN",
"BI",
"KH",
"CM",
"TD",
"KM",
"GQ",
"SZ",
"GA",
"GN",
"KG",
"LA",
"MO",
"MR",
"MN",
"NP",
"RW",
"TG",
"UZ",
"ZW",
"BJ",
"MG",
"MU",
"MZ",
"AO",
"CI",
"DJ",
"ZM",
"CD",
"CG",
"IQ",
"LY",
"TJ",
"VE",
"ET",
"XK"
],
"external_urls": {
"spotify": "https://open.spotify.com/album/5CUFurrJe05hnz189d5mDK"
},
"href": "https://api.spotify.com/v1/albums/5CUFurrJe05hnz189d5mDK",
"id": "5CUFurrJe05hnz189d5mDK",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b2735cb23d27338f4f3d848120ca",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e025cb23d27338f4f3d848120ca",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d000048515cb23d27338f4f3d848120ca",
"width": 64
}
],
"name": "33 Resolutions Per Minute",
"release_date": "2017-01-05",
"release_date_precision": "day",
"total_tracks": 18,
"type": "album",
"uri": "spotify:album:5CUFurrJe05hnz189d5mDK"
},
"artists": [
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5HYNPEO2NNBONQkp3Mvwvc"
},
"href": "https://api.spotify.com/v1/artists/5HYNPEO2NNBONQkp3Mvwvc",
"id": "5HYNPEO2NNBONQkp3Mvwvc",
"name": "Scott Bradlee's Postmodern Jukebox",
"type": "artist",
"uri": "spotify:artist:5HYNPEO2NNBONQkp3Mvwvc"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5tUXE5XK6VpNJj4LtxeI7W"
},
"href": "https://api.spotify.com/v1/artists/5tUXE5XK6VpNJj4LtxeI7W",
"id": "5tUXE5XK6VpNJj4LtxeI7W",
"name": "Kenton Chen",
"type": "artist",
"uri": "spotify:artist:5tUXE5XK6VpNJj4LtxeI7W"
}
],
"available_markets": [
"AR",
"AU",
"AT",
"BE",
"BO",
"BR",
"BG",
"CA",
"CL",
"CO",
"CR",
"CY",
"CZ",
"DK",
"DO",
"DE",
"EC",
"EE",
"SV",
"FI",
"FR",
"GR",
"GT",
"HN",
"HK",
"HU",
"IS",
"IE",
"IT",
"LV",
"LT",
"LU",
"MY",
"MT",
"MX",
"NL",
"NZ",
"NI",
"NO",
"PA",
"PY",
"PE",
"PH",
"PL",
"PT",
"SG",
"SK",
"ES",
"SE",
"CH",
"TW",
"TR",
"UY",
"US",
"GB",
"AD",
"LI",
"MC",
"ID",
"JP",
"TH",
"VN",
"RO",
"IL",
"ZA",
"SA",
"AE",
"BH",
"QA",
"OM",
"KW",
"EG",
"MA",
"DZ",
"TN",
"LB",
"JO",
"PS",
"IN",
"BY",
"KZ",
"MD",
"UA",
"AL",
"BA",
"HR",
"ME",
"MK",
"RS",
"SI",
"KR",
"BD",
"PK",
"LK",
"GH",
"KE",
"NG",
"TZ",
"UG",
"AG",
"AM",
"BS",
"BB",
"BZ",
"BT",
"BW",
"BF",
"CV",
"CW",
"DM",
"FJ",
"GM",
"GE",
"GD",
"GW",
"GY",
"HT",
"JM",
"KI",
"LS",
"LR",
"MW",
"MV",
"ML",
"MH",
"FM",
"NA",
"NR",
"NE",
"PW",
"PG",
"WS",
"SM",
"ST",
"SN",
"SC",
"SL",
"SB",
"KN",
"LC",
"VC",
"SR",
"TL",
"TO",
"TT",
"TV",
"VU",
"AZ",
"BN",
"BI",
"KH",
"CM",
"TD",
"KM",
"GQ",
"SZ",
"GA",
"GN",
"KG",
"LA",
"MO",
"MR",
"MN",
"NP",
"RW",
"TG",
"UZ",
"ZW",
"BJ",
"MG",
"MU",
"MZ",
"AO",
"CI",
"DJ",
"ZM",
"CD",
"CG",
"IQ",
"LY",
"TJ",
"VE",
"ET",
"XK"
],
"disc_number": 1,
"duration_ms": 255000,
"explicit": false,
"external_ids": {
"isrc": "GBDMT1600258"
},
"external_urls": {
"spotify": "https://open.spotify.com/track/0E32W7S52AaR4ht7i7DwDq"
},
"href": "https://api.spotify.com/v1/tracks/0E32W7S52AaR4ht7i7DwDq",
"id": "0E32W7S52AaR4ht7i7DwDq",
"is_local": false,
"name": "Closer",
"popularity": 48,
"preview_url": "https://p.scdn.co/mp3-preview/62d19079487d6859ec9c587b8e87754424cabeca?cid=2feb4729ba5145d7a7fd92f2af83cf0d",
"track_number": 1,
"type": "track",
"uri": "spotify:track:0E32W7S52AaR4ht7i7DwDq"
},
"played_at": "2023-11-29T14:25:31.873Z",
"context": {
"type": "playlist",
"href": "https://api.spotify.com/v1/playlists/37i9dQZF1DZ06evO3mw43S",
"external_urls": {
"spotify": "https://open.spotify.com/playlist/37i9dQZF1DZ06evO3mw43S"
},
"uri": "spotify:playlist:37i9dQZF1DZ06evO3mw43S"
}
}
Still here? Good. That's huge, right? Here's the transformed value:
{
"artists": [
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5HYNPEO2NNBONQkp3Mvwvc"
},
"href": "https://api.spotify.com/v1/artists/5HYNPEO2NNBONQkp3Mvwvc",
"id": "5HYNPEO2NNBONQkp3Mvwvc",
"name": "Scott Bradlee's Postmodern Jukebox",
"type": "artist",
"uri": "spotify:artist:5HYNPEO2NNBONQkp3Mvwvc"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5tUXE5XK6VpNJj4LtxeI7W"
},
"href": "https://api.spotify.com/v1/artists/5tUXE5XK6VpNJj4LtxeI7W",
"id": "5tUXE5XK6VpNJj4LtxeI7W",
"name": "Kenton Chen",
"type": "artist",
"uri": "spotify:artist:5tUXE5XK6VpNJj4LtxeI7W"
}
],
"name": "Closer",
"href": "https://open.spotify.com/track/0E32W7S52AaR4ht7i7DwDq",
"preview_url": "https://p.scdn.co/mp3-preview/62d19079487d6859ec9c587b8e87754424cabeca?cid=2feb4729ba5145d7a7fd92f2af83cf0d",
"album": "33 Resolutions Per Minute",
"album_release_date": "2017-01-05",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b2735cb23d27338f4f3d848120ca",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e025cb23d27338f4f3d848120ca",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d000048515cb23d27338f4f3d848120ca",
"width": 64
}
],
"played_at": "2023-11-29T14:25:31.873Z"
}
Much slimmer. I could strip even more as I immediately see things I'm not using, but it's good enough for now.
Coding the Preview
My initial code simply took the result of the API and rendered out the individual track items. Here's one example:
Initially, that code looked like so:
let tracks = await getTracks();
// while we get 20, limit to 18 as we're doing rows of 3
tracks = tracks.slice(0, 18);
let s = '';
tracks.forEach(t => {
let artists = t.artists.map(a => a.name).join(', ');
let html = `
<div class="track">
<a href="${t.href}" target="_new"><img src="${t.images[1].url}"></a>
<a href="${t.href}" target="_new">"${t.name}"</a> by ${artists}
</div>
`;
s += html;
});
document.querySelector('.tracks').innerHTML = s;
I began by removing the link around the image and by adding in the preview URL. I used a data attribute for that:
<img src="${t.images[1].url}" data-preview="${t.preview_url}">
Next, I needed to add event handlers to each track:
let music = document.querySelectorAll('div.track img');
music.forEach(m => {
m.addEventListener('click', e => {
// stuff
});
});
So far so good. Now for the tricky part. Playing music in JavaScript is incredibly simple. Given a URL that leads to supported audio, you can do:
let music = new Audio(theURL);
music.play();
My first implementation simply grabbed the URL:
let preview = e.currentTarget.dataset.preview;
and did that - which led to me being able to click every rendered track and hear all the music playing at once in a god-awful mashup of epic proportions. To correct this, I had to get a bit fancy:
- If a person has clicked on track A, then track B, I should stop playing A
- If a person has clicked on track A, and then A again, they probably want to stop it.
Here's how I did it:
// add event listener for music preview
let music = document.querySelectorAll('div.track img');
let audio = new Audio();
music.forEach(m => {
m.addEventListener('click', e => {
let preview = e.currentTarget.dataset.preview;
if(audio.src) {
audio.pause();
audio.currentTime = 0;
if(audio.src === preview) {
audio.src = '';
return;
}
}
audio.src = preview;
audio.play();
});
});
I basically just check the current src
. If it matches, I stop (this is done with pause
and setting the currentTime
). If the "new" URL is the same as the last one, then I just leave. Otherwise, I load up the new song.
This worked perfectly until I realized an issue. If you click to preview track A, let it play and it finishes, if you click the same track, it wouldn't start up. So I then added one more line of code:
audio.addEventListener('ended', e => { audio.src = '' });
This now lets me listen to the same preview again and again... if I want to. If you want to see the complete code, just view source over on Now or see the repo version here: https://github.com/cfjedimaster/raymondcamden2023/blob/main/src/now.liquid.