Jump to content
Visit project

Fissa,

Not only one person should decide what is playing on a party

12 minutes read
Technologies used
  • Expo
  • NativeWind
  • Next.js
  • NextAuth.js
  • Prisma
  • React Native
  • Vercel serverless
  • tRPC

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 and 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.

Group session

While building Fissa, we noticed Spotify introduced the remote group session. This was still a hidden feature at the time, changed constantly and did not seem to have any focus from Spotify.

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 wanted to give the user a personal experience too. This is why decided to give each Fissa their own color.

It is a fissa, but within our boundaries
The different color pallettes Fissa has

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 needed 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>
    )
}

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.
Explorations on how we could add songs to a Fissa

Experience Fissa

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

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

Beyond the app

Of course any app needs to work, what is 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.

It was a fun exercise to match the right animal for the different states of Fissa.

A few examples of the use of emojis in Fissa

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 are the most popular in your Fissa.

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);
            }
        }
    }
}

Determining the next song

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

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

The playlist is democratic, votes determine the queue

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 would not up-vote their own track? But this means that whenever a track is added, we must recalculate the indexes of all the tracks because their order might have changed.
  2. Users can up- or down-vote tracks anytime, leading to constant index changes. While this is not 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 tracks' 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 simpler and stable. Noice

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")
}

This only leaves us with THE algorithm which is the heart of Fissa.

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];
};