Skip to content

Music Player and Playlist: LLD Design#

Problem statement (interviewer prompt)

Design the class structure for a music player that supports playlists, shuffle, repeat (single / all), queue management, and playback state. Optimise for clean class boundaries and easy extension (gapless playback, Bluetooth output, lyrics sync).

The music player LLD sits in a sweet spot for interviews: small enough to finish in 45 minutes, rich enough to exercise Strategy, Observer, and an explicit state machine. The same model powers apps like Spotify, Apple Music, and YouTube Music under the hood.

Requirements#

Functional

  • Load a Playlist and start playback from any track.
  • Controls: play, pause, stop, next, prev, seekTo(positionMs).
  • Shuffle modes: off, random, weighted (recently-played decay).
  • Repeat modes: none, one, all.
  • Queue management: append, insert next, clear, reorder.
  • Emit playback events (started, paused, completed, error) to listeners.

Non-functional

  • Audio decode loop must never block on UI or network.
  • State transitions are atomic and observable.
  • Strategies and listeners are pluggable without recompiling Player.
  • Memory bounded: the queue holds only a small window of decoded tracks.

Class design#

classDiagram
  class Track {
    +id : String
    +title : String
    +artistId : String
    +durationMs : long
    +sourceUri : URI
  }
  class Album {
    +id : String
    +title : String
    +tracks : List~Track~
  }
  class Playlist {
    +id : String
    +name : String
    +trackIds : List~String~
    +size() int
    +trackAt(i) Track
  }
  class PlayQueue {
    -upcoming : Deque~Track~
    -history : Deque~Track~
    +current() Track
    +advance() Track
    +rewind() Track
    +enqueueNext(t)
    +append(t)
  }
  class Player {
    -state : PlaybackState
    -queue : PlayQueue
    -shuffle : ShuffleStrategy
    -repeat : RepeatStrategy
    -listeners : List~PlaybackListener~
    +play()
    +pause()
    +stop()
    +next()
    +prev()
    +seekTo(ms)
    +load(playlist : Playlist, startIndex : int)
  }
  class PlaybackState {
    <<enumeration>>
    STOPPED
    PLAYING
    PAUSED
    BUFFERING
    ERROR
  }
  class ShuffleStrategy {
    <<interface>>
    +order(tracks : List~Track~) List~Track~
  }
  class RandomShuffle
  class WeightedShuffle
  class RepeatStrategy {
    <<interface>>
    +next(queue : PlayQueue) Optional~Track~
    +prev(queue : PlayQueue) Optional~Track~
  }
  class RepeatNone
  class RepeatOne
  class RepeatAll
  class PlaybackEvent {
    +type : EventType
    +track : Track
    +positionMs : long
  }
  class PlaybackListener {
    <<interface>>
    +onEvent(e : PlaybackEvent)
  }
  Album *-- Track
  Playlist o-- Track
  Player --> PlayQueue
  Player --> ShuffleStrategy
  Player --> RepeatStrategy
  Player --> PlaybackState
  Player --> PlaybackListener
  ShuffleStrategy <|.. RandomShuffle
  ShuffleStrategy <|.. WeightedShuffle
  RepeatStrategy <|.. RepeatNone
  RepeatStrategy <|.. RepeatOne
  RepeatStrategy <|.. RepeatAll

Strategy interfaces isolate the how of pick-next from the when in Player. Observer keeps the UI layer decoupled from the decode loop.

Player state machine#

stateDiagram-v2
  [*] --> STOPPED
  STOPPED --> BUFFERING : load + play
  BUFFERING --> PLAYING : buffer ready
  PLAYING --> PAUSED : pause
  PAUSED --> PLAYING : play
  PLAYING --> BUFFERING : underrun
  PLAYING --> STOPPED : stop
  PAUSED --> STOPPED : stop
  BUFFERING --> ERROR : decode fail
  PLAYING --> ERROR : decode fail
  ERROR --> STOPPED : reset
  PLAYING --> PLAYING : next or prev (gapless)

Every transition is gated by a switch in Player.transitionTo(next). Illegal transitions throw IllegalStateException so bugs surface immediately in tests.

Core flow (play next track)#

sequenceDiagram
  participant U as User
  participant P as Player
  participant Q as PlayQueue
  participant S as ShuffleStrategy
  participant T as Track
  participant A as AudioOutput
  U->>P: next()
  P->>P: transitionTo(BUFFERING)
  P->>Q: advance()
  Q->>S: pickNext(history)
  S-->>Q: Track t
  Q-->>P: t
  P->>T: openStream(t.sourceUri)
  T-->>P: byte stream
  P->>A: write(frames)
  P->>P: transitionTo(PLAYING)
  P-->>U: PlaybackEvent(STARTED, t)

Code (Java)#

public enum PlaybackState { STOPPED, PLAYING, PAUSED, BUFFERING, ERROR }

public interface ShuffleStrategy {
    List<Track> order(List<Track> tracks);
}

public final class NoShuffle implements ShuffleStrategy {
    public List<Track> order(List<Track> tracks) { return List.copyOf(tracks); }
}

public final class RandomShuffle implements ShuffleStrategy {
    private final Random rng = new Random();
    public List<Track> order(List<Track> tracks) {
        List<Track> out = new ArrayList<>(tracks);
        Collections.shuffle(out, rng);
        return out;
    }
}

public final class WeightedShuffle implements ShuffleStrategy {
    private final Map<String, Integer> recentPlays;
    public WeightedShuffle(Map<String, Integer> recentPlays) { this.recentPlays = recentPlays; }
    public List<Track> order(List<Track> tracks) {
        List<Track> out = new ArrayList<>(tracks);
        out.sort(Comparator.comparingInt(t -> recentPlays.getOrDefault(t.id(), 0)));
        return out;
    }
}

public interface RepeatStrategy {
    Optional<Track> next(PlayQueue q);
    Optional<Track> prev(PlayQueue q);
}

public final class RepeatNone implements RepeatStrategy {
    public Optional<Track> next(PlayQueue q) { return q.advance(); }
    public Optional<Track> prev(PlayQueue q) { return q.rewind(); }
}

public final class RepeatOne implements RepeatStrategy {
    public Optional<Track> next(PlayQueue q) { return Optional.ofNullable(q.current()); }
    public Optional<Track> prev(PlayQueue q) { return Optional.ofNullable(q.current()); }
}

public final class RepeatAll implements RepeatStrategy {
    public Optional<Track> next(PlayQueue q) {
        Optional<Track> n = q.advance();
        return n.isPresent() ? n : q.rewindAll();
    }
    public Optional<Track> prev(PlayQueue q) { return q.rewind(); }
}

public final class PlayQueue {
    private final Deque<Track> upcoming = new ArrayDeque<>();
    private final Deque<Track> history = new ArrayDeque<>();
    private Track current;

    public synchronized Optional<Track> advance() {
        if (current != null) history.push(current);
        current = upcoming.pollFirst();
        return Optional.ofNullable(current);
    }
    public synchronized Optional<Track> rewind() {
        if (history.isEmpty()) return Optional.empty();
        if (current != null) upcoming.addFirst(current);
        current = history.pop();
        return Optional.of(current);
    }
    public synchronized void enqueueNext(Track t) { upcoming.addFirst(t); }
    public synchronized void append(Track t) { upcoming.addLast(t); }
    public synchronized Track current() { return current; }
    public synchronized Optional<Track> rewindAll() {
        upcoming.addFirst(current);
        while (!history.isEmpty()) upcoming.addFirst(history.pop());
        current = upcoming.pollFirst();
        return Optional.ofNullable(current);
    }
}

public final class Player {
    private final PlayQueue queue = new PlayQueue();
    private final List<PlaybackListener> listeners = new CopyOnWriteArrayList<>();
    private final ExecutorService notifier = Executors.newSingleThreadExecutor();
    private final ReentrantLock lock = new ReentrantLock();
    private volatile PlaybackState state = PlaybackState.STOPPED;
    private ShuffleStrategy shuffle = new NoShuffle();
    private RepeatStrategy repeat = new RepeatNone();
    private AudioOutput out;

    public void load(Playlist p, int startIndex) {
        lock.lock();
        try {
            queue.clear();
            List<Track> ordered = shuffle.order(p.tracks().subList(startIndex, p.size()));
            ordered.forEach(queue::append);
            queue.advance();
        } finally { lock.unlock(); }
    }

    public void play() {
        lock.lock();
        try {
            transitionTo(PlaybackState.BUFFERING);
            Track t = queue.current();
            if (t == null) { transitionTo(PlaybackState.STOPPED); return; }
            out.open(t.sourceUri());
            transitionTo(PlaybackState.PLAYING);
            fire(new PlaybackEvent(EventType.STARTED, t, 0));
        } finally { lock.unlock(); }
    }

    public void pause()  { lock.lock(); try { transitionTo(PlaybackState.PAUSED); out.pause(); } finally { lock.unlock(); } }
    public void stop()   { lock.lock(); try { transitionTo(PlaybackState.STOPPED); out.close(); } finally { lock.unlock(); } }
    public void next()   { lock.lock(); try { repeat.next(queue).ifPresent(t -> { out.close(); play(); }); } finally { lock.unlock(); } }
    public void prev()   { lock.lock(); try { repeat.prev(queue).ifPresent(t -> { out.close(); play(); }); } finally { lock.unlock(); } }
    public void seekTo(long ms) { out.seek(ms); }

    public void addListener(PlaybackListener l) { listeners.add(l); }
    private void fire(PlaybackEvent e) { notifier.submit(() -> listeners.forEach(l -> l.onEvent(e))); }

    private void transitionTo(PlaybackState next) {
        if (!Transitions.allowed(state, next)) {
            throw new IllegalStateException(state + " cannot go to " + next);
        }
        state = next;
    }
}

Concurrency#

  • Decode loop runs on its own thread; UI calls go through Player's ReentrantLock.
  • Listener notifications are dispatched on a single-threaded executor. A slow listener cannot stall audio.
  • State transitions are guarded by the same lock so a next() racing with a track-completed event still produces a consistent end state.
  • Buffering runs on a background thread that prefetches the next track once the current track crosses 80% playback. The buffer is a bounded ArrayBlockingQueue of decoded PCM frames.

Edge cases#

Scenario Handling
Empty playlist load accepts, play short-circuits to STOPPED and fires EventType.STOPPED.
Track decode failure Transition to ERROR, fire ErrorEvent, then auto-advance if RepeatStrategy allows.
Shuffle toggled mid-playback Re-order only the upcoming deque, keep current untouched so audio is uninterrupted.
skip while still BUFFERING Cancel the in-flight prefetch via Future.cancel, then start the new track's prefetch.
Playlist replaced mid-playback load clears the queue under lock, then fires PlaylistChangedEvent. The decode loop checks the queue generation counter before pulling the next frame.
Same track ID enqueued twice Allowed: PlayQueue stores Track references, not a set.

Extensions#

  • Gapless playback: pre-decode the tail of the current track and the head of the next track, crossfade frames for 50 ms before swapping the active source.
  • Bluetooth output: introduce an AudioOutput interface with LocalSpeaker, BluetoothSink, and AirPlaySink implementations; selection happens via dependency injection at Player construction.
  • Lyrics sync: subscribe a LyricsListener that consumes PlaybackEvent.positionMs and pushes the current lyric line to the UI.
  • Scrobbling (last.fm style): a ScrobbleListener batches "now-playing" pings and a final "scrobble" call once the track passes 50% or 4 minutes.
  • Equaliser: insert an AudioFilter chain between decode and AudioOutput, keeping Player agnostic.

Quick reference#

  • Track, Album, Playlist: immutable value objects, no playback logic.
  • PlayQueue: two deques (upcoming, history) plus a current pointer; thread-safe.
  • Player: facade that owns state, queue, strategies, and listeners.
  • ShuffleStrategy: NoShuffle, RandomShuffle, WeightedShuffle (by recency).
  • RepeatStrategy: RepeatNone, RepeatOne, RepeatAll.
  • States: STOPPED, PLAYING, PAUSED, BUFFERING, ERROR; transitions gated by a table.
  • Observer: PlaybackListener receives PlaybackEvent on a worker thread.
  • Concurrency: single lock on state, decode on its own thread, listeners on a notifier executor.

Refs#

  • Spotify Engineering: posts on track preloading and the desktop player architecture.
  • Apple MusicKit documentation: queue and playback APIs (generic public docs).
  • Android MediaSession / ExoPlayer: pluggable renderer and state callback patterns.

FAQ#

How do you design a music player in an LLD interview?#

Split it into a Player facade, a PlayQueue for ordering, Track and Playlist value objects, and pluggable Shuffle and Repeat strategies wired through a state machine.

What design pattern is best for shuffle and repeat modes?#

Strategy pattern: ShuffleStrategy and RepeatStrategy interfaces let you swap behaviour without touching the Player class. Each strategy implements next or prev with its own queue rules.

How is the music player state machine modelled?#

As an explicit enum of STOPPED, PLAYING, PAUSED, BUFFERING and ERROR, with a transition table that rejects invalid moves and fires events on every legal change.

How do you handle gapless playback and buffering?#

Prefetch the next track on a background thread once playback crosses a threshold, decode into a ring buffer, and crossfade audio frames at the boundary to avoid gaps.

How does the playlist scale for millions of tracks?#

Keep Playlist as metadata plus a paged list of track IDs, lazy-load pages from storage, and let PlayQueue hold only a small window of decoded Track objects.

How are listeners notified without blocking playback?#

Player fires PlaybackEvent objects onto a dedicated worker thread, and listeners run there so a slow UI update never stalls the audio decode loop.