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:

List of recent tracks

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:

A track element showing Hell to the Liars by London Grammar.

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.