Design a music player
Playlists, queue, shuffle and repeat modes as strategies, playback state.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Strategy — Swapping an algorithm at runtime by passing an object.
- Iterator and generators — Walking a collection without exposing how it is stored.
- Observer and event listeners — Telling interested objects something happened, without knowing who they are.
- Bridge — Separating an abstraction from its implementation so both can vary.
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.
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
play(tracks)builds aPlayQueue, arranges it and starts the first track.on_track_end, called by the output, asksadvance(repeat); a track starts, or None stops the player.nextbehaves like a track end, except that repeat-one still moves on: a press is an explicit request.previousrestarts the track if more than a few seconds in; otherwise it pops history.set_shufflereorders 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
PlaybackOrderwith 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:
reorderkeeps 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