Engineering Atlas › Low-Level Design

Code a music player with playlists

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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

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

Key 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

Concurrency and edge cases

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

  1. Audio glitches are audible within milliseconds, so the audio thread must never wait, which pushes all decision logic onto a separate control thread.
  2. Commands arrive from many sources, so they are serialized through one queue on that control thread, which removes locking from the queue itself.
  3. Gapless playback needs the next track before the current one ends, so "what is next" must be a cheap, side-effect-free query.
  4. 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

Leadership lens

Check yourself

  1. Why are skip and trackEnded separate 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.

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

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

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

Free resources

Open in the interactive app: practice, quiz, mock interview →