drexylbeats
for all your lofi goodness
a year ago
Introduction
So I was chatting with my brother in law about some of the music he was recently making - hell I don’t think he’s stopped making music for the last 30 years 😁 He has recently been doing some lofi beats and publishing them to Youtube but he wanted to offer to people that wanted higher quality MP3s under a nonrestrictive license for streamers and the like an easier way to get them.
He did not have any idea of how to do this so I decided that it would be a nice project to tackle a proper structured challenge in my quest at the moment to level up my front end skills in sveltekit
and to fill some time in the evenings. In fact I built drexylbeats.com before I built zarl.dev and it has had a couple of revisions. Right then OK, so now we have the problem established it was time to come up with a solution; I decided to split this into a frontend service and a backend API service. The backend service would be responsible for the querying of Youtube for any new posts that conformed to our decided naming convention and to serve the beats via a REST API, so it was time to get cracking.
Backend
API
This was a easy choice for me, a Go service to set up serve a simple REST API for serving the data to the front end. I could have probably wrapped this all up in the one sveltekit service however I wanted to stretch my Go legs again. I also didn’t go the route of an OpenAPI/Swagger spec and go file auto generation I felt it was too much work for simple service. This was only going to be a few end points to handle CRD and then triggering the scanning of the drexylbeats Youtube account. First thing I decided was to build out my own data model and store that in a Postgres database, this allows me to build out meta data which I can persist over time, rather than just storing the data based on the Youtube data fetched; which was what I initially thought I could get away with as I didn’t want the database requirement but the more I thought about it it was necessary to do this correctly. Also decided I was going to use the Youtube generated video ID as the ID to drive the API so I can use it for URL slugs etc. with out any worry.
This is the model I ended up with - and yes I AM using the same model for JSON and Database storage I’m a heretic and should be burned at the stake, don’t worry I hate myself for this as well 😁 but no one but admins even have POST capabilities.
package model
type Beat struct {
// Beat Repo ID
ID int64 `json:"id" db:"id"`
// Beat Infomation
Artist string `json:"artist" db:"artist"`
Title string `json:"title" db:"title"`
// Shows in results
Visible bool `json:"visible" db:"visible"`
// Youtube Meta information
Slug string `json:"slug" db:"slug"`
PublishedAt string `json:"published_at" db:"published_at"`
// Images
ThumbnailURL string `json:"thumbnail_url" db:"thumbnail_url"`
AvatarURL string `json:"avatar_url" db:"avatar_url"`
DownloadCount string `json:"download_count" db:"download_count"`
}
In the end I had the below endpoints for clients to access the beats, an individual beat, play the MP3 on the site and the final one to trigger a download. I separated these from just serving the MP3s as static media so I could add download count when the endpoint was triggered.
beats := e.Group("/beats")
beats.GET("", GetBeats)
beats.GET("/:slug", GetBeat)
beats.GET("/:slug/mp3", GetBeatMP3)
beats.GET("/:slug/mp3/download", GetMP3Download)
I also add the admin endpoints to handle to management of each of the beats and to trigger the Youtube scrape. I’m not going to lie, I also created a whole admin server side rendered site using Go’s template/html
library as my initial svelte attempts where not really what I wanted to achieve. In the end I had to just tell my self to just do it in svelte (which I did). The full page reloads really do detract from the user experience which was happening with this implementation. This is the list of endpoints I decided to use, jwt authentication for security, and then the refresh to get the data from Youtube, the admin version of GetBeats gets a model with more displayed fields than the model served from the client endpoints.
admin.Use(echojwt.WithConfig(config))
admin.GET("/refresh", GetRefreshBeats)
admin.GET("/beats", GetBeats)
admin.POST("/:slug/publish", PostSlugPublish)
admin.POST("/:slug/unpublish", PostSlugUnpublish)
admin.POST("/:slug/delete", PostSlugDelete)
This will come as no surprise to any one, but Google have very good, well documented Go implementations for interacting with the Youtube API the google.golang.org/api/youtube/v3
library was the way to go. And this ending up being pretty simple by getting all set up with my developer API key and then using the drexylbeats channel id and grabbing the content for the channel. To prevent this from building models of everything that was submitted to the channel we agreed that those that he wanted parsed by this would conform to a specific naming scheme. This means he can post content that he wants that wont be pulled into this, saves false positives and building out rubbish in the database. Not only did I grab the video meta data also grabbed the channel avatar to use where required.
func (b *BeatYoutubeConverter) GetBeats(ctx context.Context) ([]m.Beat, error) {
call := b.newCall(ctx)
results, err := call.Do()
if err != nil {
log.Fatalf("error getting youtube results: %v", err)
}
beats := []m.Beat{}
var avatarURL = ""
for _, item := range results.Items {
if item.Id.Kind == channel && {
avatarURL = item.Snippet.Thumbnails.High.Url
continue
}
if item.Id.Kind == video {
slug := item.Id.VideoId
if beat, ok := beatFromSnippet(slug, avatarURL, item.Snippet); ok {
beats = append(beats, beat)
}
}
}
return beats, nil
}
Take this built list of models store them in the postgres Database and move on, when initially added they are NOT available on the frontend as there has been no associated higher quality MP3. I just wrote the SQL queries myself there is NO way I’m introducing an ORM like GORM or the like even for such a simple project like this to save time, I don’t like them at the best of times.
Tools Used
With the fact that the go core library has almost everything that was required to make this there was only really a couple of dependencies.
-
labstack/echo/v4 - This is a great simple REST frame work with lots of available middleware and tools to be used. I also like the REST end point semantics of returning an error or using the context to render results, rather than the core lib blank returns.
-
google.golang.org/api/youtube/v3 - As I already mention this made my life easy as and covered all the bases for grabbing the meta data for the videos.
-
github.com/lib/pq - This is a pretty common well used Postgres driver written entirely in go.
Database
This is a simple one; Postgres data base hosted externally to the service. It is actually hosted on the recently launched neon.tech service, they offer a pretty decent free tier and the UI and UX of the site is nice, minimal and functional which I’m definitely a fan of.
Here is the schema for the beats table again I’m a heretic in storing the published_at as a text field rather than a date field. But sure you can always use the Postgres function of TO_DATE 😉 and sure anyway it’s primarily used for display only purposes. And yes the avatar_url will be repeated for each beat and will 99% of the time be the same. Thanks for your concerns, I don’t care. 😆
CREATE TABLE IF NOT EXISTS beats (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
artist TEXT NOT NULL,
title TEXT NOT NULL UNIQUE,
visible BOOL NOT NULL,
published_at TEXT NOT NULL,
thumbnail_url TEXT NOT NULL,
avatar_url TEXT NOT NULL,
download_count INT NOT NULL
);
CREATE INDEX beat_slug_idx ON beats(slug);
Frontend
This was the main point of doing this entire project in the first place; get my front end skills to a new level and use it as a structured project to give me some focus pretty much like zarl.dev here. There has also been two versions of drexylbeats.com the first version was pretty terrible but got me playing with some components and getting the build process down and the development pipeline to publish.
So with this we need two user beat interfaces one for the client and one for the admin panel they are both displaying the same data essentially but the admin page can action on the items and has some extra data displayed and has the unpublished beats. I mentioned there was different versions of so here is version one of the frontend, functional but definitely not the end result I wanted. Classic frontend by a backend developer. 😂
Version 1
It has individual players for each beat and the overall styling is not very engaging but it does use some use of tailwind animations and it does everything that I set out to do functionally.
Version 2
This was much more of an undertaking I decided I wanted to handle all the audio functions myself and build the page more as an AudioPlayer playlist from a usability perspective and also make it feel much better on a mobile device considering most of the traffic is from mobile devices. Thanks Umami.
I feel the end result is much more acceptable on both mobile and desktop, the dynamic nature; the lack of a stupid “download” button with every beat and the use of the icons instead gives a much more “app” like feel. The use of the sticky player also helps give the overall consistency of having the audio controls functionality and knowledge of whats playing while you scroll. To deliver this dynamism with out page reloads I decided to separate the logic for all the representation in to localStorageStore
which is provided by the Skeleton framework, these are wrappers over svelte stores using client side local storage this then allows updating those models store which in turn would update each corresponding beat that have been loaded as props into the BeatCard
component which is the visual representation of each beat. Here is the TypeScript code of my BeatPlayer where I can control the current audio playing and what the state of the localStorageStore
element is by keeping it as a store of the state of each beat. I hope the inline comments make it an easier read.
Here is the Beat data model as per the frontend - it not only stores the data represented in the back API service but also contains some state information about how the player and UI should render the beat. We also have here is the BeatStore class itself and its main structures like the localStorageStore
for dynamic representation of all beats, the audioElement for the playing of the beat, also the interval handle for updating the timings on the current playing beat and then a simple currentPlayingSlug to have a key reference of whats playing.
import { localStorageStore } from '@skeletonlabs/skeleton';
// Beat model
export class Beat {
readonly id?: number;
readonly slug!: string;
readonly download_count!: number;
readonly thumbnail_url!: string;
readonly avatar_url!: string;
artist!: string;
title!: string;
visible!: boolean;
playing!: boolean;
clicked!: boolean;
paused!: boolean;
duration!: number;
played!: number;
}
class BeatStore {
// Create a store for the beats
public beatsStore = localStorageStore('beats', <Map<string, Beat>>(new Map()));
public beatOrder = Array<string>();
private audioElement: HTMLAudioElement | null;
private intervalHandle: NodeJS.Timeout | null;
public currentPlayingSlug = "";
}
Here are all the functions relating to interaction with the UI of the player and how they interact with the store of beats, updating according the context of the clicked action. Making sure the rest of the beats in the map correctly show their state in relation to the clicked action was key to making the UI feel consistent.
setBeatClicked(slug: string) {
this.beatsStore.update((map) => {
// set all beats to not clicked
map.forEach((beat) => {
beat.clicked = false;
});
// set the clicked beat to clicked
const beat = map.get(slug) as Beat;
if (beat !== undefined) {
beat.clicked = true;
}
// set the map with the updated beat
map.set(slug, beat);
return map;
});
}
setBeatPlaying(slug: string) {
this.beatsStore.update((map) => {
// set all beats to not playing
map.forEach((beat) => {
beat.playing = false;
});
// set the clicked beat to playing
const beat = map.get(slug) as Beat;
// if the beat is not undefined, set it to playing
if (beat !== undefined) {
map.set(slug, { ...beat, playing: true, paused: false });
}
return map;
});
}
setBeatPaused(slug: string) {
this.beatsStore.update((map) => {
const beat = map.get(slug) as Beat;
if (beat !== undefined) {
// set the clicked beat to paused and not playing
map.set(slug, { ...beat, playing: false, paused: true });
}
return map;
});
}
previousBeat(slug: string) {
this.beatOrder.forEach((beatSlug, i) => {
if (beatSlug === slug) {
// get the previous slug and play it
const previousSlug = this.beatOrder[i - 1];
if (previousSlug) {
this.playBeat(previousSlug);
}
}
});
}
nextBeat(slug: string) {
if(!this.beatOrder) {
// if there is no beat order, play the first beat
this.playBeat(slug);
}
this.beatOrder.forEach((beatSlug, i) => {
if (beatSlug === slug) {
// get the next slug and play it if it exists, otherwise play the first beat
const nextSlug = this.beatOrder[i + 1] ? this.beatOrder[i + 1] : this.beatOrder[0];
this.playBeat(nextSlug);
}
});
}
}
Most of these functions are just wrappers around the playBeat call which is where most of the logic and state management is done including initialization of the audio elements and the interval code that needs to be kicked off every 500ms to keep playing time calculations correct and the event listeners associated to handle the end of the playing track.
playBeat(slug: string) {
if (!slug) {
const beatSlug = this.beatOrder[0]
if (beatSlug) {
slug = beatSlug;
}
}
// No this element, create one
if (!this.audioElement) {
this.audioElement = new Audio(this.mp3URL(slug));
}
// If we are playing a different beat, stop it and load the new one
if (this.currentPlayingSlug !== slug) {
this.audioElement.load();
clearInterval(this.intervalHandle ?? undefined);
this.audioElement = new Audio(this.mp3URL(slug));
}
// If we are playing the same beat
if (this.currentPlayingSlug === slug) {
// If the beat is paused, play it
if (this.audioElement.paused) {
this.setBeatPlaying(slug);
this.audioElement.play();
return;
// if the beat is playing, pause it
} else {
this.setBeatPaused(slug);
this.audioElement.pause();
return;
}
}
// Set the new beat to playing
this.currentPlayingSlug = slug;
this.setBeatPlaying(slug);
this.setupEventListeners()
// Update the beat playing time every 500ms
this.intervalHandle = setInterval(() => { this.updateBeatTime(slug) }, 500);
this.audioElement.play();
}
I’m starting to feel more comfortable writing typescript and using some more of the keywords such as readonly
and the ?
operator to make my life easier. One thing I have found myself using a lot more than I usually do is the use of the ternary operator seems like a natural flow especially when handling the defining of data from fields that can be undefined.
Individual Beats
Again I’m using a great selection of Skeleton classes and components for formatting and styling and it’s been a great as an accelerant for getting things up and running for this project. Any way for my beat component it then just stores the beat
object as per its representation in the store
giving me a really nice flow and feedback loop for my components dynamic data and states. This makes heavy use of tailwind and the skeleton framework CSS classes for formatting and colour control. The Skeleton frame work has a great token based system to make it easy to drive theming by making use of the primary
secondary
and tertiary
and many more variant-X
types to relate to the corresponding theming colours this makes styling and colour changes based on state super easy. Leveraging the svelte on:class
and the key
functionality to trigger changes including icons on the fly is also a nice easy thing to do. By far this has been the most intuitive and easy to use frame work for with in Svelte eco system for styling and components but also helper work like the localStorageStore
for keeping dynamic UI fresh and correct but manageable.
<script lang="ts">
import { Beat, BeatPlayer } from "$lib/beats/beats";
import { linear } from "svelte/easing";
import Fa from "svelte-fa";
import {
faPlay,
faPause,
faDownload,
} from "@fortawesome/free-solid-svg-icons";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
export let beat: Beat;
let url = `https://www.youtube.com/watch?v=${beat.slug}`;
function play() {
BeatPlayer.playBeat(beat.slug);
}
</script>
<div
class="bg-secondary shadow-lg w-full min-w-full"
class:variant-filled-primary={beat.playing}
>
<div class="grid">
<div
class="flex bg-secondary-800 justify-center"
class:variant-ghost-primary={beat.playing}
>
<span class="text-2xl -skew-y-2">
{beat.title}
</span>
</div>
<div class="flex justify-between p-3 variant-glass-secondary">
<a
data-sveltekit-reload
href="https://api.drexylbeats.com/beats/{beat.slug}/mp3/download"
>
<button type="button" class="btn-icon btn-icon-sm variant-filled-surface hover:variant-filled-secondary">
<Fa icon={faDownload} />
</button>
</a>
<button
type="button"
on:click={play}
class="btn-icon variant-filled hover:scale-110 hover:variant-filled-surface"
>
{#key beat.playing}
{#if !beat.playing}
<Fa icon={faPlay} />
{:else}
<Fa icon={faPause} />
{/if}
{/key}
</button>
<a
href={url}
class="hover:scale-110 hover:text-secondary"
>
<button type="button" class="btn-icon btn-icon-sm variant-filled-surface hover:variant-filled-secondary">
<Fa icon={faYoutube} />
</button>
</a>
</div>
</div>
</div>
On the main page containing the player I can then just tap in to the singleton BeatPlayer
and all iterations are interactions with this state machine containing the audio player and all beats. On this container page on the frontend I was able to leverage quite a few of the Skeleton CSS classes and importing one of their prebuilt themes to make making the player a whole lot easier. I did try to create my own theme with their theme creator but my brother in law preferred on of the canned ones. Was also able to use a nice selection of their pre-built components such as:
-
ProgressRadial
This was a such a simple way to introduce an indeterminate loading spinner for when the app initially loads and is fetching all the beats from the endpoints. Gives a nice style and is super configurable but sometimes I wish there was a way to show something other than percentage complete ofvalue
possibly an"X of Y"
value or the ability of adding more information within the circle. -
ProgressBar
I decided to use the progress bar to show the current progress of the track as it plays, to be honest I also played with theRangeSlider
this was so I could use the solid dot on the range slider as an input for skipping through the position of the track - however I felt it lacked the same styling as the ProgressBar this is something I’m going to have to come back to. -
SlideToggle
Such a simple and easy to implement way of triggering the player to slide in and out with thesvelte/transisition
slide
gives consistency and I feel is easily read which isn’t always the case with slide toggles.
On this main page we can just look through the BeatPlayer.store
and stick each beat in a BeatCard
component
<div class="grid align-top place-items-center min-w-screen">
<ul class="list w-2/3">
{#if $BeatPlayer.size > 0}
{#each [...$BeatPlayer] as [key, beat]}
<li class="min-w-full">
<BeatCard {beat} />
</li>
{/each}
{:else}
<div class="grid align-top place-items-center min-w-screen">
<ProgressRadial
stroke={100}
meter="stroke-primary-500"
track="stroke-primary-500/30"
/>
</div>
{/if}
</ul>
</div>
We can then store a reference to the current playing beat and then use it to drive the player by updating what the playingBeat
is by subscribing to the svelte store and updating based what is playing on wether or not the beat is marked as playing or not when there has been an update to the store in the BeatPlayer
.
<script lang="ts">
let playingBeat = defaultBeat;
onMount(async () => {
await BeatPlayer.loadBeats();
BeatPlayer.subscribe((bm) => {
bm.forEach((b) => {
if (b.playing) {
playingBeat = b;
return;
}
if (b.slug === playingBeat.slug) {
playingBeat = b;
}
});
});
});
</script>
Here we use the if
and showPlayer
boolean to be handled by the slide toggle to show or hide the player triggering the slide transition.
{#if showPlayer}
<div class="max-w-full overflow-hidden" transition:slide>
<div class="relative flex items-center justify-center">
<img
src={!playingBeat.thumbnail_url
? avatar
: playingBeat.thumbnail_url}
class:rounded-0={playingBeat.playing || playingBeat.paused}
class:border-0={playingBeat.playing || playingBeat.paused}
class:rounded-full={!playingBeat.playing && !playingBeat.paused}
class:h-48={playingBeat.playing || playingBeat.paused}
class:w-96={playingBeat.playing || playingBeat.paused}
class="object-scale-down border-2 border-primary-500 shadow-lg"
alt={playingBeat.title}
/>
<div
class="absolute p-4 inset-0 flex flex-col justify-end bg-gradient-to-b from-transparent to-surface-800 backdrop backdrop-blur-5 text-white"
>
<h3 class="font-bold">{playingBeat.artist}</h3>
<span class="opacity-70">{playingBeat.title}</span>
</div>
</div>
<div>
<div class="relative w-full h-1 bg-surface-800">
<ProgressBar
label="Progress Bar"
value={playingBeat.played}
max={playingBeat.duration}
meter="variant-filled-primary"
rounded="rounded-0"
/>
</div>
</div>
<div class="flex justify-between text-xs font-semibold text-gray-500 px-4 py-2">
<div>
{#key playingBeat.played}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span on:click={timeReverse} class="cursor-pointer">
{BeatPlayer.formatTime(
countDown
? playingBeat.duration - playingBeat.played
: playingBeat.played
)}
</span>
{/key}
</div>
<div class="flex space-x-3">
<button
type="button"
on:click={previous}
class="btn-icon bg-initial hover:variant-filled-secondary">
<Fa icon={faBackwardStep} />
</button>
<button
type="button"
on:click={play}
class="btn btn-lg variant-filled hover:variant-filled-primary">
{#key playingBeat.playing}
{#if !playingBeat.playing}
<Fa icon={faPlay} />
{:else}
<Fa icon={faPause} />
{/if}
{/key}
</button>
<button
type="button"
on:click={next}
class="btn-icon bg-initial hover:variant-filled-secondary">
<Fa icon={faForwardStep} />
</button>
</div>
<div>{BeatPlayer.formatTime(playingBeat.duration)}</div>
</div>
</div>
{/if}
Tools used
Feeling Im finally starting to build a repertoire of frontend components and libraries that I can use in tandem with the sveltekit
framework to build out a frontend for not only drexylbeats but also other projects that I have in mind.
- SvelteKit - This is my default now for building out frontend projects, I’m really enjoying the developer experience and the community is super helpful and has a lot of great resources to help you.
- Tailwind - This is how I’m styling my projects now, I’m really enjoying the utility first approach and the ability to theme my projects with ease and it works really well with Skeleton CSS.
- Skeleton - I’m really enjoying the Skeleton CSS library helpers and the list of prebuilt components, the project is still young and the community was super helpful over on their Discord when I had some questions getting things the way I wanted.
- FontAwesome - This is defacto icon library for me, I’m really enjoying the new SVG icons however they have locked some pretty fundamental icons behind the pro paywall like the default volume icon which is a bit of a bummer.
Deployment
Again this is my default approach - package everything up as a docker container - I have drexylbeats-www
and drexylbeats-api
running in a docker-compose file and then I have a Traefik reverse proxy that handles the routing to the correct container. All served behind HTTPS with a renewing Lets Encrypt SSL certificate. This is then hosted on a Digital Ocean droplet running Ubuntu 22.04. super easy to setup and maintain. One thing I do need to do is set up my own container registry as I’m currently using the c8n.io registry which is fine for now but I would like to have my own self hosted registry.
Conclusion
Overall I feel it’s been a success and my front end skills are improving, I’m really enjoying the SvelteKit framework and the community around it. I’m also really enjoying the Tailwind CSS framework in conjunction with Skeleton CSS library and using their component library. I’m looking forward to building out more features and improving the overall experience of the site I have a few ideas that I want to implement and I’m looking forward to getting stuck in.
Improvements
- Auto-Publish to Spotify - My brother has the required accounts with 3rd parties services to be able to publish to Spotify, Apple Music, Amazon Music and other streaming services. I would like to be able to automate the publishing process so that when he uploads a beat to the site it will automatically publish to the streaming services.
- Volume Slider - An in page volume slider would be nice to have, instead of having to use the system volume slider.
- Playlist Rearrangement - Adding drag and drop functionality to the playlist so that you can rearrange the order of the beats to your liking.
- Dynamic Theming - Leveraging the Skeleton CSS library to allow users to change the theme of the site from one of the many prebuilt themes.
- Summary when player is hidden - When the player is hidden there is a small area which would be ideal to display a summary of the currently playing beat and possibly a smaller progress bar and play/pause button a mini player if you will.