The main reason I love coding is that it gives endless scope for creativity. For instance - if I decide, completely on a whim, that I want to build a personal home streaming app, then damn it I can build a personal home streaming app and who’s going to stop me? You??

Ahem. OK, maybe this wasn’t completely on a whim. After all, my discontent with the major streaming platforms has been brewing for some time. Put it down to the indecisive malaise that so often accompanies infinite and immediate choice, or the sense of devaluation of music and the arts (not to mention the artists) when the prevailing commercial model dictates that we not pay directly for it any more, or just simply the fact that I want to feel like I actually own something again.

It’s also tiring wading through a sea of bland to find the good stuff… as Steven Wilson put it:

Hear the sound of music
Drifting in the aisles
Elevator Prozac
Stretching on for miles

Music of the future
Will not entertain
It’s only meant to repress
And neutralize your brain

Which feels quite prescient given the dominance of coffee shop background playlists, intentionally sped-up music for TikTok clips and ’lo-fi beats to study to’ (OK those are kind of great and I listen to them all the time but you take my point).

Old man yells at cloud

Moving swiftly on… let’s unpack the process of building the thing.

Brief disclaimer: I am aware Plex Media Server and other services like it exist! This project wasn’t about filling a gap that no-one else had, but rather to learn, be curious and enjoy the process of building a thing.

It’s also nice to have something that I have complete control over - if I or Harri want a new feature or a small quality of life improvement, or we’d like it to behave in a way that’s very specific to our wants/needs, then there is nothing stopping me from just making that happen! šŸš€

The Hardware

Before I could build an app to stream my music, I first needed somewhere to store it in its digital form. I wanted a source that my custom app could pull from, but that my Sonos system could also hook into.

Solution: ✨ NAS ✨

NAS = Network Assisted Storage - think a hard drive that your Wi-Fi can talk to

Nas Illmatic artwork

Not that one…

I already had a 5TB HDD kicking about that wasn’t really being used. I also really, really wanted an excuse to buy a Raspberry Pi.

Raspberry Pi = a single-board micro computer, whose latest iteration at time of writing (the RPi 5) boasts a quad-core processor and up to 16GB of RAM - a lot of grunt for something that can fit in a shirt pocket šŸ¤šŸ»

So, with the HDD being responsible for the ‘S’ in NAS and the RPi handling the ‘NA’ bit, I had a decent, if basic, foundation for hosting my music. One reason an RPi was a particularly good fit was that it could also function as a web server, both exposing my music files to the Wi-Fi network AND hosting the files that would comprise my streaming app (more on that shortly).

I used this tutorial for configuring the RPi + HDD as a NAS, which was extremely helpful. It makes use of samba for exposing the music files over the local Wi-Fi network via SMB (a protocol used for file sharing, and one which Sonos natively understands), and I opted to install the headless version of Raspberry Pi OS since the intent was to run it as a server, with no requirement for a visual desktop or other form of GUI given that I could do everything I needed via SSH.

SSH icon

While we’re on the topic of storage, I knew that I’d need some form of database for storing information about my music files, as it would be inefficient and slow for my app to read raw metadata directly off the files every single time it was required. For this fast-access persistent storage, I opted for SQLite. This fit the bill perfectly as it’s small, fast and reliable, just like my RPi (no jokes, thank you). Additionally, it’s so ubiquitous as to barely even count as a dev dependency, since it comes pre-installed on so many devices.

In terms of where to store the database, I could have chosen to have it directly on the RPi itself. However, given that uses an SD card for its persistent storage, which has limited space and is not necessarily ideal in terms of long-term reliability, I opted for storing the database on the HDD along with my music files.

Since that about covers hardware and storage, let’s move on to talking about the app itself…

The Software

Given that the whole NAS setup was pretty new to me, for the app itself I wanted to work within an ecosystem with which I was already pretty familiar. Since I’m a web developer first and foremost, I opted to build a web app rather than a native mobile app.

I would soon learn that when streaming off a phone, a native app would have given me much greater control and flexibility over things like background media handling and uninterrupted playback. C’est la vie!

I’ve worked on a large number of React projects over the years, so that was a no-brainer as far as a front-end library. Couple that with Vite as a build tool and TypeScript for type-safety (and because I know of no earthly reason not to use it on fresh projects these days), and you’ve got a nice lil’ modern web dev setup going ✨

React, Vite and TypeScript logos

I knew I’d need a server-hosted API layer to act as a middleman between my front-end app and my SQLite database. This is due to the fact that front-end code runs in a user’s browser, but the database is hosted centrally on my RPi (and giving unfettered direct access to my database over the public internet would be… let’s say a bad idea).

I’m used to writing APIs in .NET, given the company I work for is a Microsoft partner, however to keep things in broadly a similar ecosystem, I decided to branch out and use Node.js with Express. I’d never been hands on with Express before, but I’d seen it used through work and knew it could be combined with Sequelize as an ORM to quickly and conveniently spin up an API layer that can talk to a SQLite database.

ORM = Object-Relational Mapper. A tool that helps with the process of pulling raw data out of a database and converting it into in-memory data structures that our API code can use.

I knew I wanted the app to be as self-contained and lightweight as possible, so I opted to host the front-end and the API together under a single instance of nginx as my web server, with API requests proxied to the Express API layer over a specific port.

To minimise the number of dependencies my RPi would need to have baked in, I opted to package everything as a Docker container. This meant that all I needed to do to prepare the ground on the RPi was to install Docker, as a Docker container not only provides a neat package for the code that comprises an app or server environment, but also all the third-party dependencies it requires to run (so Node.js, Express, SQLite, Vite, React etc., all remains effectively unknown to the RPi - this saves a LOT of admin overhead!).

Docker logo

Giving the Docker container access to the music files and SQLite database on the HDD was then as simple as setting up a Docker Volume, which is a means of mapping a filepath within a running container to a filepath on the host machine (by design, stuff running in a Docker container cannot just reach out and grab whatever it wants from the file system of the host).

I used docker-compose to run the Docker container on the RPi, which helped control stuff like making sure the container starts every time the RPi is switched on, as well as providing a convenient way of managing environment variables and volume path mapping.

Animated gif of Peardrop architecture

The basic architecture

So with all this in place, I had a boilerplate React + Vite app that I could access in a browser on my home network. The last piece of the puzzle was making it so I could connect to it from outside my home (otherwise my roadtrip playlist would be rendered pretty useless…).

Enter, Cloudflare Tunnel. Cloudflare tunnels are a piece of mysterious voodoo magic that allow you to set up an outbound-only persistent connection from a server to Cloudflare’s servers in the cloud, over which you can then serve HTTP web requests (amongst other things). What’s especially neat about them is that they encrypt all traffic for you, and can be configured with authentication from various federated authentication providers (such as Google, Microsoft, Okta etc.). The use of an outbound-only tunnel also means that your home IP is never required or addressed at any point, and even if it was, there would still be a layer of obfuscation given that all traffic is served through Cloudflare’s network (kind of like how a VPN can hide or spoof your location by routing your web traffic through remote servers in other countries).

Setting up the tunnel was as simple as installing a cloudflared daemon on the RPi to create and maintain the outbound tunnel, and then applying a public domain name to it via the Cloudflare portal. I had rarely been so excited to see an empty boilerplate app as when I drove to the office and loaded it up from there!

Peardrop šŸ

At its fundamental level, the remit of this project was to:

  1. Allow Harri and I to listen to our personal music collection from anywhere in the world
  2. Do so in a slick, fast and useable way with a comparable UX (User Experience) to world leaders such as Spotify

My friends in marketing tell me that being slick requires having a snappy name. I’ll avoid causing boredom and engender a sense of intrigue by not detailing how we landed on the name ‘Peardrop’, but pear-drop your best guesses in the comment section and I’ll give a 0-10 score for how close you got (maybe).

Landing page of Peardrop app

In the vein of not going into too much detail I’ll not outline every package used in building the app, other than to call out some notable mentions:

  • Mantine - Component Library
  • React Query - Data fetching and caching
  • React Router - Routing
  • zustand - State Management (+ session storage)
  • music-metadata - Utility package for extracting metadata from music files (such as track title, artist name, artwork etc.)

That last one was particularly handy for syncing metadata from my music files into the SQLite database. At the moment I have a Sync button in the app that initiates the process of crawling all the music files on the NAS and adding/updating entries in the database (fast-glob was very handy here too).

This Sync button also invalidates all React Query caches, meaning that the next time I navigate to the main tracklist page (for example), the app will query the API again to retrieve the tracks rather than relying on the cached data. If the caches weren’t invalidated, the data in the database would be up-to-date but the app wouldn’t show any newly added or updated tracks unless I refreshed the page.

Album page of Peardrop app

React Router is helpful for routing between different parts of the app in a navigation menu, but can also be used to help build a data-driven breadcrumb navigation like the one in the screenshot above. Below is an example route:

{
  path: ":albumId",
  element: <AlbumView />,
  loader: albumLoader(queryClient),
  handle: {
    crumb: (params: Params<"albumId">, state: any) => (
      <PeardropLink
        route={`../albums/${params.albumId}`}
        state={state}
      >
        {state?.albumName ?? params.albumId}
      </PeardropLink>
    ),
  },
}

Which is picked up by this Breadcrumb component:

const Breadcrumb: React.FC = () => {
  const location = useLocation();
  const matches: CrumbMatches[] = useMatches();

  const crumbs = matches
    .filter((match) => !!(match.handle as CrumbHandle)?.crumb)
    .map((match) =>
      (match.handle as CrumbHandle).crumb!(match.params, location.state)
    );

  return crumbs.length > 1 ? (
    <Breadcrumbs visibleFrom="md" separator="Ā»" pb={25}>
      {crumbs.map((crumb, index) => (
        <React.Fragment key={index}>{crumb}</React.Fragment>
      ))}
    </Breadcrumbs>
  ) : null;
};

When it came to building the interface for the actual player, this article by LogRocket was very helpful. It makes use of the requestAnimationFrame API for updating the visible track progress as it plays, which is an efficient way of handling animations that need to be rendered multiple times a second.

It was also really fun implementing a mobile design for the player, which makes it easier to use on a phone.

Mobile player

I wish I knew Bill Evans Trio too…

Mantine has some great hooks available out of the box that help with determining what kind of device you’re using, e.g.,:

const isMobile = useMediaQuery(`(max-width: ${em(750)})`);

Something else I learned about, which I never knew existed before starting on this project, was the Media Session API. This allows you to set metadata about the currently playing media on the user device, but in such a way that common devices and operating systems will display it in their native media interfaces. Cars will also pull from this data when connected to a phone via Bluetooth or CarPlay, and so will display the media information rather than just the name of the web app that’s playing it.

The Media Session API also lets you specify what the native media controls should do, so you can specify that the Play/Pause button on your phone’s lock screen should call your Play/Pause handler in code, for example.

const previousCurrentTrackValue = useRef<Track | null>(null);

useEffect(() => {
  if (!("mediaSession" in navigator)) return;

  // When a track is loaded in, set media session handlers
  if (previousCurrentTrackValue.current === null && currentTrack !== null) {
    navigator.mediaSession.setActionHandler("play", togglePlay);
    navigator.mediaSession.setActionHandler("pause", togglePlay);
    navigator.mediaSession.setActionHandler(
      "previoustrack",
      skipToPreviousTrack
    );
    navigator.mediaSession.setActionHandler("nexttrack", skipToNextTrack);
  } else if (
    previousCurrentTrackValue.current !== null &&
    currentTrack === null
  ) {
    navigator.mediaSession.setActionHandler("play", null);
    navigator.mediaSession.setActionHandler("pause", null);
    navigator.mediaSession.setActionHandler("previoustrack", null);
    navigator.mediaSession.setActionHandler("nexttrack", null);
  }

  const trackWithUrls = !!currentTrack ? withUrls(currentTrack) : null;

  navigator.mediaSession.metadata = new MediaMetadata({
    title: trackWithUrls?.title ?? "",
    artist: trackWithUrls?.artist ?? trackWithUrls?.album?.artistName ?? "",
    album: trackWithUrls?.album?.name ?? "",
    artwork: !trackWithUrls
      ? []
      : [
          {
            src: trackWithUrls.thumbnailUrl!,
            sizes: "96x96",
            type: "image/jpeg",
          },
          // ... further thumbnail sizes here
        ],
  });

  return () => {
    navigator.mediaSession.metadata = new MediaMetadata({
      title: "",
      artist: "",
      album: "",
      artwork: [],
    });
  };
}, [currentTrackId]);

There’s all sorts of other stuff I could touch on, but I should probably leave it there! Suffice it to say it’s been a really engaging project to work on, and one that I’ve stuck with because of how directly useful and inspiring it is to me. Music plays a huge role in my life, and it wouldn’t be overstating it to say that having a platform which minimises the friction involved in listening to it has brought me back to something of the enjoyment I used to have as a teenager - of just sticking my headphones on and getting completely lost in it all šŸŽ§

Music lights my brain up in a way that podcasts, however interesting they may be, never do.

What’s Next?

AI is supposedly in vogue right now (I’m told), so probably an AI DJ of some sort. Something where you can tell it in a sentence or two how you’re feeling and what sort of thing you want to listen to and it’ll curate a playlist to match. This should be doable on an RPi given it’s the training that’s computationally expensive, more so than the actual running of the model šŸ¤–

Other than that, maybe an ‘Ambilight’ mode (or some other non-copyrighted term) where the Mantine theme adjusts dynamically according to the artwork of whatever track you’re currently listening to. I’m also planning to extend it to play audiobooks as well as music, and possibly even films too (although we’ve got a lot less of our own digital or physical media on that front so it could be of limited use).

If you’ve made it this far, let me know in the comments what killer features you would add to your own app, and thank you very much for reading!