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 3tracks = 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 previewlet 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.