Code a music player with playlists
Open in the interactive app: practice, quiz, mock interview →In one minute
- As an object-design question, "Spotify" is a catalog, a library, playlists and, above all, the player: what plays next when the song ends, when the user taps skip, taps back, turns on shuffle or queues a track. The streaming backend is a different interview, covered in Design Spotify.
- The heart of the design is the play queue, which merges three sources in a fixed priority: the user's manual queue first, then the current context (album, playlist, artist radio), with history behind for "previous".
- Two small decisions separate strong answers: auto-advance and user skip are different events (repeat-one replays on the first and not the second), and "previous" restarts the current track if it has been playing for more than a few seconds.
- Shuffle is a view over the context, not a mutation of it. Keep the original order, keep the current track where it is, shuffle the rest with an injected random source, and restore the original order when shuffle turns off.
- The problem shape is ordering with several producers (user, context, remote devices) and one consumer (the audio engine). The same shape appears in a CI pipeline's job queue with priority overrides, and in a print spooler that takes "print this next".
Requirements
Functional:
- Browse tracks, albums, artists; save tracks to a library; create, rename, reorder and delete playlists.
- Start playback from a context (an album or playlist) at a chosen track.
- Play, pause, seek, skip, previous; repeat modes off, context and track (the names Spotify's public Web API uses); shuffle on or off.
- Add to queue: queued tracks play before the rest of the context, in the order they were added, and survive a change of context.
- Show "next up" so the user sees what will play.
Non-functional:
- The next track must be known early enough to prefetch and decode it, so transitions are gapless.
- All player commands are applied in one order, whether they come from the phone's UI, a lock-screen control, or another device.
- Deterministic tests: shuffle takes its randomness as a parameter.
Out of scope: audio delivery, royalties and play counting, recommendations, social features and podcasts. The play-count side lives in the system design lesson.
Entities and relationships
- Track is a value: id, title, duration, artist ids, album id. It is immutable from the player's point of view.
- Album, Artist and Playlist are containers. A playlist is an ordered list of PlaylistEntry values (track id, added by, added at), because the same track can appear twice and the entry, not the track, is what a reorder moves.
- Library is a member's saved tracks, albums and followed playlists.
- PlaybackContext is what playback was started from: a source (playlist id, album id) and the ordered track ids at that moment. It is a snapshot, so a collaborator editing the playlist does not reorder what is already playing.
- PlayQueue owns the context order, the manual queue, the history, the shuffle flag and the repeat mode. It answers "what is next" and "what is previous".
- Player owns playback state (stopped, playing, paused), position and volume, and talks to the AudioEngine. It asks the queue what to play and reports events.
- ShuffleStrategy produces an order; RandomSource and Clock are injected.
Relationships: a player has one queue (composition). The queue holds one context at a time and references tracks by id. Playlists aggregate entries. Nothing in the queue owns a track.
Class diagram
classDiagram
class Track {
+id: TrackId
+title: string
+durationMs: int
+artistIds: ArtistId[]
}
class Playlist {
+id: PlaylistId
+name: string
-entries: PlaylistEntry[]
+add(trackId)
+move(from, to)
}
class PlaybackContext {
+source: string
+trackIds: TrackId[]
}
class PlayQueue {
-order: int[]
-position: int
-manual: Deque~TrackId~
-history: HistoryEntry[]
+playContext(ctx, start)
+addToQueue(trackId)
+skip() TrackId
+trackEnded() TrackId
+previous(positionMs) TrackId
+upcoming(n) TrackId[]
}
class Player {
-state: PlayerState
-positionMs: int
+handle(command)
}
class PlayerCommand {
<<interface>>
+apply(player)
}
class ShuffleStrategy {
<<interface>>
+order(n, anchor, rng) int[]
}
class AudioEngine {
<<interface>>
+load(trackId)
+preload(trackId)
}
class PlaybackListener {
<<interface>>
+onEvent(event)
}
Player "1" *-- "1" PlayQueue
PlayQueue --> PlaybackContext
PlayQueue --> ShuffleStrategy
Player --> AudioEngine
Player --> PlaybackListener
Player ..> PlayerCommand
Playlist "1" *-- "many" PlaylistEntry
PlaylistEntry --> Track
FisherYates ..|> ShuffleStrategy
ArtistSpread ..|> ShuffleStrategyKey flows
The flow that tests the design is "a queued track interrupts a playlist, then playback resumes where the playlist left off". Commands from every source go through one command queue on the player's control thread.
sequenceDiagram
participant U as User
participant P as Player
participant Q as PlayQueue
participant A as AudioEngine
participant L as Listeners
U->>P: PlayContext(playlist, track 3)
P->>Q: playContext(ctx, 3)
Q-->>P: t3
P->>A: load(t3)
P->>Q: upcoming(1)
Q-->>P: t4
P->>A: preload(t4)
U->>P: AddToQueue(x)
P->>Q: addToQueue(x)
P->>A: preload(x)
A-->>P: trackEnded(t3)
P->>Q: trackEnded()
Q-->>P: x (from manual queue)
P->>L: PlaybackStarted(x)
A-->>P: trackEnded(x)
P->>Q: trackEnded()
Q-->>P: t4 (context resumes)Design patterns used and why
- Command for player actions. Play, pause, skip, seek and add-to-queue are objects placed on one queue and applied in order by a single control loop. That gives one ordering for the phone UI, headphone buttons and a remote device (Spotify Connect is the product version of this), and makes logging and replaying a session trivial.
- State for the player: stopped, playing, paused, buffering. Each state decides what
playandpausemean; pausing a stopped player is a no-op, not an error. - Strategy for shuffle. Plain Fisher-Yates is uniformly random, and users complain that it is "not random" when two songs by the same artist land together. Spotify's engineering blog described in 2014 moving to an algorithm that spreads each artist's tracks across the list. That is a second strategy behind the same interface, and the queue does not change.
- Iterator, in spirit. The queue is a cursor over a sequence with policies (repeat, shuffle, manual inserts). Callers ask for
next, never for the internal list, which keeps the representation free to change. - Memento for history. Each history entry records which track was playing, where in the context it was and whether it came from the manual queue, so "previous" can restore the cursor precisely.
- Observer for everything downstream of playback: the now-playing UI, lock-screen metadata, scrobbling and the play-event reporter that feeds stream counting. The player publishes events; it never calls those components.
Concurrency and edge cases
- One writer. UI taps, media-key callbacks, the audio engine's "track ended" and remote commands arrive on different threads. Do not lock the queue from all of them; post commands to one control loop. The queue itself then needs no locking, and the order of effects is the order of commands.
- Stale "track ended". The user taps skip a few milliseconds before the old track's end-of-stream callback arrives. If both advance, a track is skipped silently. Tag every load with a play id and ignore
trackEndedfor any play id that is no longer current. - Repeat track versus skip. With repeat set to track, the end of a song replays it, but a user's skip still moves on. Two methods (
trackEnded,skip) make that rule obvious; onenext(force)flag hides it. - Previous. Past roughly three seconds into a track, "previous" restarts it; before that, it goes back. If the current track came from the manual queue, going back puts it back at the head of the queue so it is not lost.
- Shuffle toggled mid-context. Turning shuffle on keeps the current track playing and shuffles the rest; turning it off resumes the original order from the current track. History stores context indices, not positions, so "previous" still works after a toggle.
- End of context. With repeat off, playback stops at the end; with repeat context, it wraps to the start of the current order. A new context clears history, since positions from the old context mean nothing in the new one, but keeps the manual queue.
- Duplicates. A playlist can hold the same track twice, so the queue works with indices into the context, never with "find this track id".
Down the stack
Layer 1The contract
skip returns the head of the manual queue if there is one, else the next track in the current order, wrapping only under repeat context, else nothing. trackEnded behaves like skip except that under repeat track it returns the current track again. previous(p) restarts when p is at least 3,000 ms or there is no history, and otherwise restores the previous track and cursor exactly. upcoming(n) predicts what successive skips would return, so the player can prefetch.
Layer 2The mechanism
The context is an array of track ids and the play order is an array of indices into it (identity when unshuffled). The cursor is an integer. The manual queue is a deque: O(1) at both ends, which "add to queue" and "put back on previous" both need. History is a stack of small records. Shuffle with Fisher-Yates walks the array from the end, swapping each element with a random earlier one including itself, O(n) with n calls to the random source and every permutation equally likely. Keeping the current track at position zero and shuffling only the rest is what makes toggling shuffle seamless. The audio side keeps two decoders: one playing, one preloading upcoming(1).
Layer 3The machine
A 3.5-minute track at 160 kbit/s is about 4 MB compressed. Decoded to 16-bit stereo PCM at 44.1 kHz it is 176,400 bytes per second, about 37 MB, which is why players decode a few seconds ahead into a ring buffer rather than decoding whole tracks. The audio callback runs on a real-time thread with a buffer of perhaps 512 frames, about 11.6 ms at 44.1 kHz; if it blocks on a mutex, allocates, or waits on a garbage-collection pause, the buffer underruns and the user hears a click. So the control loop (commands, queue logic) and the audio thread share only a lock-free single-producer, single-consumer ring of samples and a few atomics. Queue operations themselves are nanoseconds to microseconds; shuffling a 10,000-track playlist is 10,000 swaps in a contiguous integer array, well under a millisecond.
Why it's built this way
- Audio glitches are audible within milliseconds, so the audio thread must never wait, which pushes all decision logic onto a separate control thread.
- Commands arrive from many sources, so they are serialized through one queue on that control thread, which removes locking from the queue itself.
- Gapless playback needs the next track before the current one ends, so "what is next" must be a cheap, side-effect-free query.
- Users toggle shuffle and repeat freely and expect "previous" to work, so the queue keeps the original order and an index-based history instead of mutating the context.
Extensibility
- Smart shuffle. A new
ShuffleStrategythat spaces artists apart; tests inject a deterministic random source and assert on spacing, not on an exact order. - Autoplay. When the context ends with repeat off, ask a
ContinuationProviderfor similar tracks instead of stopping. The queue gains one hook; the recommender stays outside. - Group sessions and remote control. Several devices send commands to one player. Commands already serialize, so the change is transport plus authorization (who may skip), not queue logic.
- Offline.
upcominggains a filter for tracks that are not downloaded; the queue skips them rather than stalling. - Podcasts. Episodes need resume positions and different skip semantics (jump 15 seconds), which argues for a separate
EpisodeQueuebehind the same player rather than flags insidePlayQueue.
Leadership lens
- Build first: the queue and its tests (manual-queue priority, repeat, previous, shuffle toggle), because every bug there is user-visible and every client platform reimplements it if there is no shared core.
- Ask in review: which thread owns queue state; how stale end-of-track events are ignored; what "previous" does for a queued track; whether shuffle is a mutation or a view.
- Consider one shared playback core (for example in C++ or Rust compiled for each platform) versus per-platform rewrites. The core costs build complexity; rewrites cost behaviour drift between iOS, Android and desktop that support will hear about.
- Instrument the decisions: skip within 30 seconds, previous within 3 seconds and shuffle toggles are product signals and also the fastest way to spot a queue regression after release.
Check yourself
Why are
skipandtrackEndedseparate operations?Show answer
They differ under repeat track: the end of a song should replay it, but a user's skip should move on. One method would need a flag that every caller must remember to set.
The user queues track X, X starts, and the user taps previous after one second. What should happen to X?
Show answer
The previous track plays and X goes back to the head of the manual queue, so the next skip plays X again instead of losing it.
Why does the queue keep the context order as indices instead of reordering the track list in place when shuffling?
Show answer
So turning shuffle off restores the original order from the current track, duplicates stay distinct, and history entries remain valid across a toggle.
Why should the audio thread not take the same lock as the queue?
Show answer
The audio callback has a deadline of a few milliseconds; waiting on a lock held by UI or network work causes underruns. Decisions happen on the control thread and only samples cross to the audio thread.
Watch
- Build Spotify, Music Player App (Coder Army): a low-level design lecture that builds a music player's classes, useful to compare with the queue-centred model here.
- Command Pattern, Design Patterns ep 7 (Christopher Okhravi): the pattern behind serializing player actions from many sources.
- Iterator Pattern, Design Patterns ep 16 (Christopher Okhravi): traversal separated from the collection, the idea behind the play queue's cursor.
Free resources
- Fisher-Yates shuffle (Wikipedia): the algorithm, its proof of uniformity and the classic off-by-one bias.
- Set repeat mode (Spotify Web API): the public contract for repeat states, matching the modes used here.
- Add item to playback queue (Spotify Web API): how the real product exposes "add to queue" to remote controllers.
- Command pattern (Refactoring.Guru): structure and trade-offs for player commands.
- Memento pattern (Refactoring.Guru): the snapshot idea behind history entries.