Now Playing: A Web Component

One of the aspects of personal websites that I love is the ability to have a little bit of creative expression and “features” that are just for fun. Case in point here: the ability to see what I’m currently listening to on Spotify. Just for kicks I put this quick component together to do just that. This isn’t very complicated at all, and would be simple to set up on your own site if you’re so inclined.

Here’s what it looks like:

If you’re seeing an album cover and song name, it’s because I’m listening to music right now at this very moment! Neat. If you see a “Not Playing” label, it’s because I’m not listening currently. If now is one of those silent moments, here’s an image of what it looks like:

Example of the Now Playing component when a track is playing in Spotify.
Example of the component when a track is playing in Spotify.

The song’s name, album, artist, link, and artwork all are populated from Spotify. I’m using the Get Playback State endpoint to get these details. (You’ll also need a developer account and an app created on Spotify to do this.)

The Code

Here’s the exact code that I used to build the first version of this script. It may change over time, but these are the basics:

Web Component Markup

Inside of your HTML, place the markup for the component itself. I’m using Tailwind CSS for the styling, but you can certainly add your own.

There are templating slots for each of the data points I’m using here (artist, track name, artwork, etc). I’m using slots here to avoid specific class name selectors in the JavaScript below.

In the example above, I’m also using some placeholder markup and a fallback image to display when there’s no active track playing. I didn’t include that in the code below for simplicity’s sake.

<now-playing class="hidden">
  <div class="group max-w-sm" slot="container">
    <a
      href="#"
      slot="link"
      class="border border-gray-200 rounded-lg p-2 flex gap-4 items-center group-hover:bg-gray-200"
      target="_blank"
      rel="noopener noreferrer"
    >
      <span class="max-w-16">
        <img src="" slot="artwork" alt="" class="rounded-lg">
      </span>
      <span class="flex flex-col">
        <span slot="artist" class="text-xs text-gray-500"></span>
        <span slot="track" class="text-sm text-gray-900 font-bold"></span>
        <span slot="album" class="text-xs text-gray-500"></span>
      </span>
    </a>
  </div>
</now-playing>

Custom Element JavaScript

For the JavaScript, I’m using a custom element class to set up the element, fetch the data when it is displayed, and adjust the slots with real data. If I were making this for a more important production website, I’d make this a bit more robust and safe. (Add some code to check to make sure each of the slots exist before I update it, only fetch the data once per page, etc.) But that’s not necessary for this site.

class NowPlaying extends HTMLElement {
  async connectedCallback() {
    await this.fetchNowPlaying()
  }

  async fetchNowPlaying () {
    const response = await fetch('https://nowplaying.johntornow.com/')

    // not playing
    if (response.status !== 200) {
      return
    }

    const json = await response.json()

    // not playing
    if (json.isPlaying !== true) {
      return
    }

    this.classList.remove('hidden')

    this.querySelector('[slot="album"]').innerText = json.album
    this.querySelector('[slot="artist"]').innerText = json.artist
    this.querySelector('[slot="track"]').innerText = json.track
    this.querySelector('[slot="artwork"]').src = json.artwork
    this.querySelector('[slot="artwork"]').alt = `${json.artist} - ${json.track}`
    this.querySelector('[slot="link"]').href = json.link
  }
}

customElements.define('now-playing', NowPlaying)

Cloudflare Worker

There’s a fetch call in the JavaScript above to a subdomain that I’m using for a Cloudflare worker that handles the server logic. Cloudflare workers are simply amazing, and I love using them for little tools like this. This particular worker is just a file of JavaScript that runs whenever the root of the domain is requested.

In order to get the Spotify bits to work here, make sure to set up a new app on the Spotify Developer portal. Then, ensure you’ve copied the client id, and client secret into environment variables for the worker.

I also manually created a refresh token for myself using a standard Oauth 2 flow. I did this outside of the app just with Paw, but there’s a bunch of other ways to get that value if need be.

A note on scopes: when setting up your initial access token, make sure to use the user-read-playback-state scope so you can read playback information.

There’s one other path I took with the worker: some slight caching on the data. I don’t know what limits Spotify has on this information, but I didn’t like the idea of hitting its servers each page load. So for 3 minutes I’m storing the latest response from Spotify in Cloudflare’s Workers KV storage. KV is very handy for this Varnish-style of caching. (I’m also storing my latest access token in KV as well, so each request doesn’t need to re-authenticate.)

Since we’re loading this worker from a client-side fetch request, I’ve wrapped all responses with some very loose CORS headers to allow it to work properly in the browser.

import { Buffer } from 'node:buffer'
import get from 'lodash.get'

// Get current access token from Spotify, using refresh token if need be
const fetchAccessToken = async (env) => {
  const clientId = env.CLIENT_ID
  const clientSecret = env.CLIENT_SECRET
  const originalToken = env.REFRESH_TOKEN

  const savedAccessToken = await env.spotify.get("access_token")

  if (savedAccessToken) {
    return savedAccessToken
  }

  const response = await fetch('https://accounts.spotify.com/api/token', {
    method: 'POST',
    headers: {
      'content-type': 'application/x-www-form-urlencoded',
      'Authorization': 'Basic ' + (new Buffer.from(clientId + ':' + clientSecret).toString('base64'))
    },

    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: originalToken,
      client_id: clientId
    })
  })

  if (response.status !== 200) {
    return null
  }

  const json = await response.json()
  const newAccessToken = json.access_token

  // store the new token in KV store for an hour
  await env.spotify.put("access_token", newAccessToken, { expirationTtl: json.expires_in })

  return newAccessToken
}

const fetchSpotifyData = async (accessToken) => {
  console.debug('fetching current track from Spotify...')

  const response = await fetch('https://api.spotify.com/v1/me/player', {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${accessToken}`
    }
  })

  // only 200 OK status will continue
  if (response.status !== 200) {
    return null
  }

  const json = await response.json()

  // for debugging spotify response
  // console.log('spotify data:', json)

  const result = {
    isPlaying: false,
  }

  try {
    result.artist = get(json, 'item.artists[0].name')
    result.album = get(json, 'item.album.name')
    result.track = get(json, 'item.name')
    result.artwork = get(json, 'item.album.images[0].url')
    result.link = get(json, 'item.external_urls.spotify')
    result.isPlaying = json.is_playing
  } catch (e) {
    console.error('error parsing spotify data:', e)
  }

  return result
}

// to allow this to be loaded from my site (and any other really)
const responseWithCors = response => {
  response.headers.set('Access-Control-Allow-Origin', '*')
  response.headers.set('Access-Control-Allow-Methods', 'GET,OPTIONS,HEAD')
  response.headers.set('Access-Control-Max-Age', '86400')

  return response
}

export default {
  async fetch(request, env, ctx) {
    // check for a cached now playing track in KV
    const cachedCurrentTrack = await env.spotify.get("current_track")

    if (cachedCurrentTrack) {
      console.debug('Current track found in cache')

      const cachedData = JSON.parse(cachedCurrentTrack)
      return responseWithCors(Response.json(cachedData))
    }

    const accessToken = await fetchAccessToken(env)

    // if no token, we'll just skip the rest and assume nothing is playing
    if (!accessToken) {
      return responseWithCors(new Response(null, { status: 204 }))
    }

    const spotifyData = await fetchSpotifyData(accessToken)

    // spotify returns 204 no content if nothing playing, so we'll do the same
    if (spotifyData === null) {
      return responseWithCors(new Response(null, { status: 204 }))
    }

      // store the current track in KV for a few minutes for caching
    await env.spotify.put("current_track", JSON.stringify(spotifyData), { expirationTtl: (60 * 3) })

    return responseWithCors(Response.json(spotifyData))
  }
}

That’s about it. There’s some nuance to figure out with the Cloudflare workers for sure. But the basics are right here.

If you found this useful, let me know!

This concept was inspired by Cory Dransfeldt’s post about the same thing. Thanks Cory for the cool idea.