The 100 problems, solved / Applications and services / 099 · Design a music player

Design a music player

Playlists, queue, shuffle and repeat modes as strategies, playback state.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A music player looks like a media app and is really a queue with modes. This is the class-level sibling of the playlist shuffler, with playback state and listeners around it.

  • A listener builds playlists and plays one or a queue of tracks.
  • A listener plays, pauses, resumes, skips forward and back.
  • A listener sets shuffle on or off, and repeat to off, one or all.
  • A listener adds a track to play next, ahead of the rest of the queue.
  • The player moves on automatically when a track ends.
  • The screen and a scrobbler are told of track changes.

Out of scope: streaming, recommendations, licensing, downloads and decoding. Non-functional constraints: back must return to the track actually played before, even in shuffle; shuffle plays each track once per pass; the audio device is behind an interface so the logic is testable with no sound; a new play order is a new class.

2. Core entities and relationships

  • Track: immutable id, title, artist, duration.
  • Playlist: an ordered, named list of tracks.
  • PlaybackOrder: a strategy that yields the order of positions; sequential and shuffled implementations.
  • PlayQueue: the current order, cursor, history and play-next list.
  • RepeatMode: OFF, ONE, ALL.
  • PlayerState: STOPPED, PLAYING, PAUSED.
  • AudioOutput: an interface over the platform's audio.
  • MusicPlayer: the facade.
  • PlayerListener: observers.
LibraryPlayerDevice and listenersorderednotifiesTrackid: strtitle: strseconds: intPlaylistname: strtracks: listPlaybackOrderarrange(n, start)ShuffleOrderrng: RandomPlayQueuetracks: listorder: listcursor: intplay_next: dequehistory: listadvance(repeat)back()current()MusicPlayerstate: PlayerStaterepeat: RepeatModequeue: PlayQueueplay(playlist)pause()resume()next()previous()set_shuffle(on)on_track_end()AudioOutputstart(track)pause()resume()stop()PlayerListeneron_track(track)on_state(state)

Playlists aggregate tracks, because one track sits in many playlists. The player composes its queue, which exists only for the current session; changing the queue never changes the playlist it came from.

3. Interfaces and key methods

class PlaybackOrder(Protocol):
    def arrange(self, n: int, start: int) -> list[int]:
        """A permutation of range(n) beginning with start."""

class AudioOutput(Protocol):
    def start(self, track: Track) -> None: ...
    def pause(self) -> None: ...
    def resume(self) -> None: ...
    def stop(self) -> None: ...

class PlayQueue:
    def current(self) -> Optional[Track]: ...
    def advance(self, repeat: RepeatMode) -> Optional[Track]:
        """Next track honouring play_next and repeat; None at the end."""
    def back(self) -> Optional[Track]:
        """The previously played track, from history."""
    def reorder(self, order: PlaybackOrder) -> None:
        """Rearrange what is left, keeping the current track playing."""

class MusicPlayer:
    def play(self, tracks: list[Track]) -> None: ...
    def pause(self) -> None: ...
    def resume(self) -> None: ...
    def next(self) -> None: ...
    def previous(self) -> None: ...
    def on_track_end(self) -> None: ...

Invariants: order is always a permutation of the queue's indices; the output is playing exactly when the state is PLAYING; history holds what was truly played, so back works the same in every mode; toggling shuffle never interrupts the current track.

4. Design patterns and the choices behind them

State for what the buttons mean

Pause means nothing when stopped. A small state enum with guards covers three states with thin behaviour. Separate state classes would be warranted if each state had many methods, and a small player does not.

Play order as a strategy

Sequential and shuffle, and later "smart shuffle" that spaces out artists, all produce a permutation. As a strategy, each is one method, and shuffling with a seeded random source is deterministic in tests. Fisher-Yates gives every order equal chance; the common sort-by-random-key shortcut does not always.

The queue is an iterator with history

PlayQueue is an iterator over the session, with the addition that it can go back. Keeping the order as a permutation of indices, not a shuffled copy of tracks, means turning shuffle off restores the real order without losing the position.

A bridge to the device

What the player does and how sound comes out vary independently: phone speaker, Bluetooth, a cast device. Holding an AudioOutput is the bridge pattern, and a fake output records calls in tests. Listeners keep the now-playing screen and scrobbler out of playback logic.

5. Core flow and code

  1. play(tracks) builds a PlayQueue, arranges it and starts the first track.
  2. on_track_end, called by the output, asks advance(repeat); a track starts, or None stops the player.
  3. next behaves like a track end, except that repeat-one still moves on: a press is an explicit request.
  4. previous restarts the track if more than a few seconds in; otherwise it pops history.
  5. set_shuffle reorders only what remains.
from __future__ import annotations
import random
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Protocol


class RepeatMode(Enum):
    OFF = "off"; ONE = "one"; ALL = "all"


class PlayerState(Enum):
    STOPPED = "stopped"; PLAYING = "playing"; PAUSED = "paused"


@dataclass(frozen=True)
class Track:
    id: str
    title: str
    seconds: int


@dataclass
class Playlist:
    name: str
    tracks: list[Track] = field(default_factory=list)


class PlaybackOrder(Protocol):
    def arrange(self, n: int, start: int) -> list[int]: ...


class SequentialOrder:
    def arrange(self, n: int, start: int) -> list[int]:
        return list(range(start, n)) + list(range(start))


class ShuffleOrder:
    def __init__(self, rng: Optional[random.Random] = None) -> None:
        self.rng = rng or random.Random()

    def arrange(self, n: int, start: int) -> list[int]:
        rest = [i for i in range(n) if i != start]
        for i in range(len(rest) - 1, 0, -1):         # Fisher-Yates
            j = self.rng.randint(0, i)
            rest[i], rest[j] = rest[j], rest[i]
        return [start] + rest


class PlayQueue:
    def __init__(self, tracks: list[Track], order: PlaybackOrder) -> None:
        self.tracks = list(tracks)
        self.order = order.arrange(len(tracks), 0)
        self.cursor = 0
        self.play_next: deque[Track] = deque()
        self.history: list[Track] = []
        self.now: Optional[Track] = self.tracks[self.order[0]] if tracks else None

    def current(self) -> Optional[Track]:
        return self.now

    def _go(self, track: Optional[Track]) -> Optional[Track]:
        if self.now is not None and track is not None:
            self.history.append(self.now)
        self.now = track
        return track

    def advance(self, repeat: RepeatMode) -> Optional[Track]:
        if repeat is RepeatMode.ONE:
            return self.now
        if self.play_next:
            return self._go(self.play_next.popleft())
        if self.cursor + 1 < len(self.order):
            self.cursor += 1
        elif repeat is RepeatMode.ALL:
            self.cursor = 0
        else:
            self.now = None
            return None
        return self._go(self.tracks[self.order[self.cursor]])

    def back(self) -> Optional[Track]:
        if not self.history:
            return self.now
        self.now = self.history.pop()
        return self.now

    def reorder(self, order: PlaybackOrder) -> None:
        if self.now in self.tracks:
            start = self.tracks.index(self.now)
        else:
            start = self.order[self.cursor]
        self.order = order.arrange(len(self.tracks), start)
        self.cursor = 0


class AudioOutput(Protocol):
    def start(self, track: Track) -> None: ...
    def pause(self) -> None: ...
    def resume(self) -> None: ...
    def stop(self) -> None: ...


class PlayerListener(Protocol):
    def on_track(self, track: Optional[Track]) -> None: ...
    def on_state(self, state: PlayerState) -> None: ...


class MusicPlayer:
    RESTART_AFTER = 3.0

    def __init__(self, output: AudioOutput) -> None:
        self.output = output
        self.state = PlayerState.STOPPED
        self.repeat = RepeatMode.OFF
        self.shuffled = False
        self.queue: Optional[PlayQueue] = None
        self.listeners: list[PlayerListener] = []
        self.rng = random.Random()

    def _order(self) -> PlaybackOrder:
        return ShuffleOrder(self.rng) if self.shuffled else SequentialOrder()

    def _set_state(self, state: PlayerState) -> None:
        self.state = state
        for l in self.listeners:
            l.on_state(state)

    def _start(self, track: Optional[Track]) -> None:
        for l in self.listeners:
            l.on_track(track)
        if track is None:
            self.output.stop()
            self._set_state(PlayerState.STOPPED)
        else:
            self.output.start(track)
            self._set_state(PlayerState.PLAYING)

    def play(self, tracks: list[Track]) -> None:
        self.queue = PlayQueue(tracks, self._order())
        self._start(self.queue.current())

    def pause(self) -> None:
        if self.state is PlayerState.PLAYING:
            self.output.pause()
            self._set_state(PlayerState.PAUSED)

    def resume(self) -> None:
        if self.state is PlayerState.PAUSED:
            self.output.resume()
            self._set_state(PlayerState.PLAYING)

    def on_track_end(self) -> None:
        if self.queue:
            self._start(self.queue.advance(self.repeat))

    def next(self) -> None:
        if self.queue:
            mode = RepeatMode.OFF if self.repeat is RepeatMode.ONE else self.repeat
            self._start(self.queue.advance(mode))

    def previous(self, position: float = 0.0) -> None:
        if not self.queue:
            return
        if position > self.RESTART_AFTER:
            self._start(self.queue.current())
        else:
            self._start(self.queue.back())

    def add_next(self, track: Track) -> None:
        if self.queue:
            self.queue.play_next.append(track)

    def set_shuffle(self, on: bool) -> None:
        self.shuffled = on
        if self.queue:
            self.queue.reorder(self._order())

6. Extensions, concurrency and what interviewers probe

  • Back in shuffle: a new random pick is the wrong answer. History records what was played, so back is the same in every mode.
  • No repeat within k: a cooldown across passes needs a new order that excludes the last k tracks; that is a PlaybackOrder with memory, covered in the shuffler problem.
  • Repeat one and skip: a track end repeats, an explicit next moves on. The two entry points exist for this reason.
  • Shuffle toggled mid-queue: reorder keeps the current track and rearranges from it. Turning shuffle off resumes the real order after the current track.
  • Threads: the audio callback for a track end arrives on another thread, and can race with a skip, so a stale end must not advance twice. Tag each start with a generation number and ignore ends from older generations, or post everything to one event loop.
  • Test first: a seeded shuffle plays every track exactly once; repeat ALL wraps; back returns the actual previous track; pause when stopped does nothing; a fake output sees one start per track.
  • Empty and edited queues: playing an empty list must stop cleanly, and removing the current track from the queue should advance rather than crash; decide both before writing advance.
  • Common mistake: shuffling the track list in place, after which the original order is gone.

02Where this question was reported

  • LLD Mastery · Google-tagged (Hard); playlists, recommendations
  • GitHub · awesome-low-level-design, Hard
  • Codemia · Codemia Medium; 'Music Streaming App' Hard