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-stack
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 boundaries
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 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>
)
}
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.
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 Spotify
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.
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
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 Fissa
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 queue
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:
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.
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.
Beside some minor Expo Application Services to make the release process easier. ↩