Oh hi there, good day to you. My name is Sander, a passionate developer with a creative mind™.
Jump to content
Sander

Fissa

13 min read
Not only one person should decide what's playing on a party

Everyone can be the DJ

Having friends at a party with a bad taste in music stinks. This is what Milan and myself have experienced countless times.

Instead of complaining about it constantly we decided to do something about it.

As I am always looking for an excuse to start a new pet-project to learn and explore new technologies, Fissa seemed like a perfect opportunity to do so.

A picture having several emojis which are used throughout the fissa application

Requirements

Before starting the project we set some ground rules:

  1. Fissa should be available on both iOS and Android
  2. No onboarding! Like jokes, apps lose their charm when you have to explain them
  3. A fissa never stops, there is always music playing
  4. Have some fun, this is not work

Three screens for e

The journey to expo

I was wary of using a new cross-platform framework like expo after having some subpar experiences with Phonegap, Ionic and Flutter.

However, we never give up, technology moves forward, and there seems to be a new kid on the block; React native.

As with all the cross-platform tools I felt more like a configurator than a developer. Constantly fighting the framework to get things done, and having to write platform specific code anyway.

What happened to the fun?

Was this the end of the project?

I take great inspiration from Theo Browne which is how I stumbled upon the T3-stack.

No more configuration1, just coding

The promise of the T3-stackThe T3-stack comes neatly pre-configured with tailwind css, e

The great reset

No, not that one.

Working with TypeScript (for the past 5 years) has been a real game-changer and I would never go without, neither should any sane person.

However, TypeScript still has it’s quirks.

Luckily, our TypeScript wizard Matt Pocock released an awesome tool right when we started with Fissa; ts-reset

    import "@total-typescript/ts-reset";

// .filter got smarter!
const filteredArray = [1, 2, undefined].filter(Boolean); // number[]
    
  

It's a fissa, but within our boundariesThe different color pallettes Fissa has

A colorful fissa

Music is a blend of personal expression and social connection. By creating a collaborative playlist, and sharing your favorite tunes, we create a social experience.

We also want to give the user a personal experience. This is why decided to give each Fissa their own color palette.

Tailwind

Tailwind offers a great way to configure a theme. Initially this was the setup we went for.

    // Tailwind config.js
const pinkey = {
  100: "#FFCAF7",
  500: "#FF5FE5",
  900: "#150423",
  gradient: ["#FF5FE5", "#FF5F72"],
};

// ...

const themes = [pinkey, orangy, greeny, blueey, sunney, limey];

const theme = themes[Math.floor(Math.random() * themes.length)];

const config = {
  theme: {
    extend: {
      colors: { theme },
      textColor: { theme },
      backgroundColor: { theme },
      ringColor: { theme },
    },
  },
};
    
  

For NativeWind, the tailwind helper for React Native, this does not work out-of-the-box well and we do need to expose the runtime variables to use the dynamic theme.

    // Tailwind config.js

// ...

const theme = themes[Math.floor(Math.random() * themes.length)];

module.exports.theme = theme;
    
  

We can then use this exposed variable within our components.

    // Component.tsx
import { theme } from "@fissa/tailwind-config";

const Component = () => {
    return (
        <SafeAreaView style={{ backgroundColor: theme["900"] }}>
          { /* ... */ }
        </SafeAreaView>
    )
}
    
  

screens displaying the different color palettes being used inside of the app

Less === more

Milan and myself are both big fans of Spotify and we recognize its strength. They focus on one thing, and they do it very well; explore and discover music.

Although we want to keep users fully engaged in Fissa, we would never be able to do the exploration of music as well as Spotify does.

Therefore we put our focus on managing a collaborative playlist and utilize Spotify for the rest.

Do one thing, and do it well.

Group session

While building Fissa, we noticed Spotify introduced the remote group session.

This is still a hidden feature, changes constantly and does not seem to have any focus from Spotify.

Explorations on how we could add songs to a fissa via Spotifythree different e

Experience Fissa

Before I lose you in the details, I would like to invite you to experience Fissa yourself.

Make a Fissa and enjoy the tunes while you read the rest of the article.

Free with limits

Spotify provides us with an awesome API. Unfortunately, and understandably, it is rate limited.

Users need to be signed in to their Spotify account to use Fissa, therefore we can utilize the unlimited API calls from personal accounts. Everything which is not related to the Fissa itself, like searching for songs, is done via the Fissa app and stored using zustand.

This reduces the load on Fissa’s servers and simultaneously also allows us to host more Fissas without hitting Spotify’s usage restrictions.

Win-Win.

    export const useTracks = (trackIds?: string[]) => {
  const { addTracks, tracks, spotify } = useSpotifyStore();

  const cachedTrackIds = useMemo(() => new Set(tracks.map(({ id }) => id)), [tracks]);

  const uncachedTrackIds = useMemo(
    () => trackIds?.filter((trackId) => !cachedTrackIds.has(trackId)) ?? [],
    [trackIds, cachedTrackIds],
  );

  const requestedTracks = useMemo(
    () => trackIds?.map((trackId) => tracks.find(({ id }) => id === trackId)).filter(Boolean) ?? [],
    [trackIds, tracks],
  );

  useMemo(async () => {
    const promises = splitInChunks(uncachedTrackIds).map(
      async (chunk) => (await spotify.getTracks(chunk)).tracks,
    );

    const newTracks = (await Promise.all(promises)).flat();

    if (newTracks.length) addTracks(newTracks);
  }, [uncachedTrackIds, addTracks, spotify]);

  return requestedTracks;
};
    
  

See the full implementation on GitHub

Beyond the app

Of course any app needs to work, what’s the point of having a non-functioning app. Besides playing tracks at a party we wanted Fissa to be a fun experience, both for the users and for us creating Fissa.

By playing around with animal-eomjis as copy we try to lighten the mood of Fissa and bring some extra joy to the experience.

Besides, it was a fun exercise to find the right animal for the different states of Fissa.

A few examples of the use of emojis in FissaA few e

The fissa never stops

What is more annoying than being at a party, finally having the courage to show your moves and then the musics stops.

This should never happen, the Fissa never stops.

Whenever the queue is about to get empty, Fissa will add recommended tracks. These will be semi-random tracks, utilizing the Spotify recommendations, but most importantly they will be based on the tracks which gained the most votes.

    class FissaService {
    playNextTrack = async () => {
        // ...

        if (nextTracks.length <= TRACKS_BEFORE_ADDING_RECOMMENDATIONS) { 
            const withPositiveScore = tracks.filter(({ totalScore }) => totalScore > 0); 
            const tracksToMap = withPositiveScore.length ? withPositiveScore : tracks; 

            const trackIds = tracksToMap 
                .map(({ trackId }) => trackId) 
                .sort(randomSort) 
                .slice(0, TRACKS_BEFORE_ADDING_RECOMMENDATIONS); 

            try {
                await this.trackService.addRecommendedTracks(pin, trackIds, access_token!); 
            } catch (e) {
                logger.error(`${fissa.pin}, failed adding recommended tracks`, e);
            }
        }
    }
}
    
  

The playlist is democratic, votes determine the queueThe more votes a song has, the sooner it will be played on the Fissa

Determining the next song

As the Fissa is a collaborative playlist, users determine the order of the songs. This is done by voting on the songs. This proved to be the most challenging part of the project.

Strap-on your seatbelt, the next part will contain a lot of nerd info.

Initially we stored the index of the songs directly into the database. This way we put an unique index on the track for data integrity, and we could easily sort the tracks by their index.

    model Track {
    index  Int @db.SmallInt
    trackId String @map("track_id") @db.VarChar(22)

    fissa Fissa @relation(fields: [pin], references: [pin], onDelete: Cascade)
    pin String @db.VarChar(4)
    
    @@id([pin, trackId])
    @@unique([pin, index])
    @@map("tracks")
}
    
  

Easy peasy, lemon squeezy right? Well, not quite. This approach has a few drawbacks:

  1. When we add a song to Fissa, we up-vote it. Who wouldn’t up-vote their own track, right? But this means that whenever a track is added, we must recalculate the indexes of all the tracks below it because their order might change.
  2. Users can up- or down-vote tracks anytime, leading to constant index changes. While this isn’t inherently problematic, it can disrupt ongoing index recalculations.
  3. Because of the unique index constraint in prisma (read postgres), we had to update it twice: once to clear the new index and once to update the moved track’s index.
    class FissaService {
  reorderPlaylist = async (pin: string) => {
    const { currentIndex, tracks } = await this.getRoomDetailedInformation(pin);

    try {
      const { updates, fakeUpdates, newCurrentIndex } =
        this.generateTrackIndexUpdates(tracks, currentIndex);

      await this.db.$transaction(async (transaction) => {
        if (currentIndex !== newCurrentIndex) {
          await transaction.room.update({
            where: { pin },
            data: { currentIndex: newCurrentIndex },
          });
        }

        // (1) Clear out the indexes
        await transaction.room.update({ 
          where: { pin }, 
          data: { tracks: { updateMany: fakeUpdates } }, 
        }); 

        // (2) Set the correct indexes
        await transaction.room.update({ 
          where: { pin }, 
          data: { tracks: { updateMany: updates } }, 
        }); 
      });
    } catch (e) {
      console.log(e);
    }
  };
}
    
  

And for the final nail in the coffin, Fissa is hosted serverless on vercel. As nobody pays for their pet-project in this day-and-age, we only have 10 seconds to perform any operation. Recalculating and updating indexes of Fissas with 50+ songs proved to be not possible. Even with the latest 9x improvements in prisma serverless cold starts.

The path of least resistance

Eventually we settled on inferring the position of a track based on its’ score. The score is updated each time a user votes on a track and the score will be cleared whenever the song is being played.

By using the logic which was already in Fissa, we could remove a lot of code and make the whole process a lot more stable.

    model Track {
    index Int @db.SmallInt
    score Int @db.SmallInt @default(0) 
    trackId String @map("track_id") @db.VarChar(22)

    fissa Fissa @relation(fields: [pin], references: [pin], onDelete: Cascade)
    pin String @db.VarChar(4)

    @@unique([pin, trackId])
    @@map("tracks")
}
    
  

THE algorithm

    type Dates = { lastUpdateAt: Date; createdAt: Date; };

export type SortableTrack = Dates & {
  score: number;
  trackId: string;
  hasBeenPlayed: boolean;
};

const sortTrack = (date: keyof Dates) => (a: SortableTrack, b: SortableTrack) => {
  const aTime = a[date].getTime();
  const bTime = b[date].getTime();

  if (a.score !== b.score) return b.score - a.score;  
  if (aTime === bTime) return a.trackId.localeCompare(b.trackId);

  return aTime - bTime;
};

export const sortFissaTracksOrder = <T extends SortableTrack>(
  tracks?: T[],
  activeTrackId?: string | null,
) => {
  if (!tracks) return [];

  const { played, unplayed, active } = tracks.reduce(
    (acc, track) => {
      const { hasBeenPlayed, trackId } = track;

      if (trackId === activeTrackId) acc.active = track;
      else if (hasBeenPlayed) acc.played.push(track);
      else acc.unplayed.push(track);

      return acc;
    },
    { played: [] as T[], unplayed: [] as T[], active: null as T | null },
  );

  const sortedPlayedTracks = played.sort(sortTrack("lastUpdateAt"));
  const sortedUnplayedTracks = unplayed.sort(sortTrack("createdAt"));

  return [...sortedPlayedTracks, ...(active ? [active] : []), ...sortedUnplayedTracks];
};
    
  

Phew, you made it all the way through the though end. That last part was code heavy. Here, have a🍪!

Code can be scary right, but it’s also fun while making your imaginations come to life.

In collaboration with

Source code

Footnotes

  1. Beside some minor Expo Application Services to make the release process easier.