My Qwik app that authenticates with Spotify

A small tool that lets you put a "marker" on a song and have a button to recall that position with a click.

ยท

7 min read

My Qwik app that authenticates with Spotify

Hi, I am developing a small tool for learning Qwik and helping me study classical music orchestration.

Studying scores from other composers is crucial for learning orchestration. I find it useful to listen to the orchestral track while following the score visually. Oftentimes, it's beneficial to revisit specific sections.

That's where my tool comes in! It allows you to place a "marker" on the timeline of a song. Then, with a simple click of a button, you can easily jump back to that marker.

I put this tool together over the weekend and thought it would be great to share my process with you. Keep in mind that it still has some limitations, but I believe in the saying "release early and release often." By sharing it with you, I hope to gather feedback and make improvements along the way.

What are we building?

We're building a Qwik-based app (because Qwik is cool) that interfaces with Spotify (which is the only supported "music provider" at the moment) and lets you put a marker and recall it with a click.

For styling, I chose to use Tailwind and DaisyUI. We will deploy everything to Cloudflare Pages.

During my exploration of the Spotify API documentation, I discovered a helpful guide. I have successfully converted this guide into a Qwik-based implementation, and I want to share the process in this blog post. You can find the reference guide I used here: https://developer.spotify.com/documentation/web-playback-sdk/howtos/web-app-player

๐Ÿ’ก
Please keep in mind that the code is currently in its early stages of development, just a few steps after the proof of concept (POC). As a result, many aspects of the implementation are still rough and not optimized to their full potential.

Let's see some code

Spotify authentication

First, I created a "Login with Spotify" component:

export const SpotifyLogin = component$(() => {
  return (
    <button
      onClick$={async () => {
        const url = `https://accounts.spotify.com/authorize/?${await auth_query_params()}`;
        window.location.href = url;
      }}
      class="btn"
    >
      Spotify Login
    </button>
  );
});

And for the auth_query_params functions I used a function that runs on the server because I wanted to access my environment variables for the Spotify client ID and the redirect callback URL defined in my Spotify developer dashboard.


function generateRandomString(length: number): string {
  let text = "";
  const possible =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

  for (let i = 0; i < length; i++) {
    text += possible.charAt(Math.floor(Math.random() * possible.length));
  }
  return text;
}

const auth_query_params = server$(function () {
  const scope = "user-read-playback-state user-modify-playback-state";
  const state = generateRandomString(32);
  const spotify_client_id = this.env.get("SPOTIFY_CLIENT_ID") as string;

  const auth_query_parameters = new URLSearchParams({
    response_type: "code",
    client_id: spotify_client_id,
    scope: scope,
    redirect_uri: this.env.get("SPOTIFY_REDIRECT_URL") as string,
    state: state,
  });
  return auth_query_parameters.toString();
});

Upon reconsideration, running the application client-side could be a viable option, provided that Qwik allows for convenient access to the necessary environment variables. It might be worth exploring this possibility and giving it a try.

That ridiculous unsafe random string generation function is taken straight from the Spotify docs, so I'm fine with it. I initially used crypto from Node because we're on the server-side with this function, but that didn't play nice with Cloudflare Workers. More on that later.

After the user is redirected to the Spotify authentication page, they are redirected back to my "/" redirect URL. In this index route, I need to process the request and get a token for the user. I do it with a Qwik routeLoader as follows:

export const useSpotifyAuthCallback = routeLoader$(async ({ query, env }) => {
  const code = query.get("code") as string;

  if (!code) {
    return {};
  }

  const response = await fetch("https://accounts.spotify.com/api/token", {
    method: "POST",
    headers: {
      Authorization:
        "Basic " +
        btoa(
          env.get("SPOTIFY_CLIENT_ID") + ":" + env.get("SPOTIFY_CLIENT_SECRET")
        ),
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      code: code,
      redirect_uri: env.get("SPOTIFY_REDIRECT_URL") as string,
      grant_type: "authorization_code",
    }),
  });

  if (response.status !== 200) {
    console.error(await response.text());
    return { error: "invalid response from Spotify API" };
  }
  const parsed = await response.json();
  const access_token = parsed.access_token;
  if (!access_token) {
    return { error: "no access_token from Spotify API" };
  }

  return { access_token };
});

And the index component looks like this at the moment. It uses the hook above to get the token:

export default component$(() => {
  const spotifyCallback = useSpotifyAuthCallback();
  const loc = useLocation();

  useVisibleTask$(() => {
    const params = new URLSearchParams(loc.url.search);
    if (params.get("code")) {
      window.history.replaceState({}, "", "/");
    }
  });

  return (
    <>
      <h1>Hi ๐Ÿ‘‹</h1>
      {spotifyCallback.value.error && (
        <div class="alert alert-error">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            class="stroke-current shrink-0 h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
          >
            <path
              stroke-linecap="round"
              stroke-linejoin="round"
              stroke-width="2"
              d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
            />
          </svg>
          <span>{spotifyCallback.value.error}</span>
        </div>
      )}
      {!spotifyCallback.value.access_token && <SpotifyLogin />}
      {spotifyCallback.value.access_token && (
        <SpotifyPlayer token={spotifyCallback.value.access_token} />
      )}
    </>
  );
});

As you might have noticed, I'm using a visible task hook to remove the ?code=... from the URL that Spotify authentication concatenates to the callback URL after I get that value.

DaisyUI dark theme and Storybook

Initially, I had a the <SpotifyPlayer /> component has all the logic of advancing the song progress and setting a marker, etc. After that worked, I decided to refactor and extract the "view" from the player itself and create, simply, a <Player /> component. For that, I installed Storybook and started playing around with my component in isolation. I have a few comments about this small styling adventure.

When Storybook was installed, it detected Tailwind or something like that and inserted some code in preview.ts for handling the theming. Using DaisyUI, I wanted to change the themes using their data-theme attribute. So it required a small change to Storybook as follows:

  decorators: [
    withThemeByDataAttribute({
      themes: {
        light: "light",
        dark: "night",
      },
      defaultTheme: "dark",
      attributeName: "data-theme",
    }),
  ],

I use the "night" DaisyUI theme and not the default "dark". This made it work with Storybook's theme switcher.

For the Qwik side, in root.tsx I added the data-theme attribute

  return (
    <QwikCityProvider>
      <head>
        <meta charSet="utf-8" />
        <link rel="manifest" href="/manifest.json" />
        <RouterHead />
      </head>
      <body lang="en" data-theme="night" class="min-h-screen">
        <RouterOutlet />
        <ServiceWorkerRegister />
      </body>
    </QwikCityProvider>
  );

I don't support theme switching at the moment, but that's planned :)

Nightmares with Cloudflare Pages

The initial attempt to deploy the app to Cloudflare proved to be quite challenging. Do you recall the previously used unsafe string generation function? Well, initially, I incorporated crypto from Node instead. I also made use of dotenv. However, things took a turn when I deployed the app to Cloudflare.

Upon integrating Cloudflare with my Qwik app, I encountered errors during the Cloudflare build process. It complained about missing Node modules required for its workers. After conducting some research, I discovered a seemingly straightforward solution: enabling nodejs_compat in my projects. Unfortunately, this approach proved ineffective despite trying several random ideas and conducting more in-depth research. Eventually, I had to remove all Node libraries from my code before Cloudflare would successfully deploy the app.

In Spotify's example code, Buffer was utilized at some point for the bearer token. To ensure compatibility with Cloudflare workers, I had to make the necessary adjustment and replace it with a web API, specifically utilizing btoa.

And one final roadblock I overcame was accessing environment variables in the workers. It turns out the QwikCity Cloudflare adapter handles that, I think, and then you can access env or this.env in the Qwik backend parts. So after defining my env vars in the Cloudflare dashboard, I could get the actual values needed and not undefined everywhere.

Current limitations

  1. Currently, each time the page is refreshed, it necessitates re-authentication with Spotify. However, this inconvenience will be resolved once a more secure authentication method is implemented, as described in Spotify's documentation. This alternative approach allows for saving token information in local storage.

  2. At present, the app only supports a single marker. Ideally, multiple markers should be available for enhanced functionality.

  3. To enhance the user experience, it would also be beneficial to save markers in local storage based on the song ID, allowing for easy retrieval when the same song is played again.

  4. Improving the user interface is an ongoing endeavor, as there is always room for enhancement.

  5. Proper error handling has not been fully implemented, resulting in unexpected behavior when encountering certain situations such as receiving a 429 rate limit error from Spotify. Addressing this issue and ensuring robust error handling is a priority.

  6. Supporting the option to switch between light and dark mode or implementing other themes is currently not available. However, it is a desired feature that will be considered for future development.

Demo

My Spotify app is still in "development mode" and I'm not 100% sure what it means, but I'll post my URL regardless. Maybe let me know if it lets you sign in in the comments.

Find the app here: https://song-markers.amireldor.com/

I am still not ready to publish the source code for the repo, but I will open-source it when in one of my next iterations.

Goodbye for now :)

ย