Pebellslearn by building
0%
RoadmapMatrixCatalogSandboxSign in
Linked Lists, Stacks & Queues  ›  Capstone Project

Capstone — Player Engine

35 minThree structures driving one player
You're building a piece ofTunebox — the player
This piece — run_player(): Drives playback with the queue, the history and the linked playlist together.
Scenario A listener starts a playlist, queues a friend's track, skips ahead, then hits back. Tunebox's player engine replays those presses through the queue, the history stack and the linked playlist, and reports what's playing.
Your task
Build run_player(playlist, commands). Keep the playlist as a linked list with a cursor, the up-next queue as a deque and the history as a stack, then apply each command: ["next"] pushes the current track onto history and plays the front of the queue, or else the next playlist track (None when the playlist runs out); ["back"] puts the current track at the front of the queue and pops history; ["queue", title] adds to the back of the queue; ["insert", title] splices title in after the cursor; ["remove", title] unlinks the first playlist node with that title, stepping the cursor back if it was on it. Return {"now", "history", "up_next", "playlist"}. Example: run_player(["A", "B", "C"], [["next"], ["queue", "X"], ["next"], ["next"], ["back"]]) → {"now": "X", "history": ["A"], "up_next": ["B"], "playlist": ["A", "B", "C"]}.

Capstone — Player Engine

Every button on Tunebox's player ends up here. The playlist is a linked chain you can splice while it plays, the up-next queue cuts in ahead of it, and the history stack remembers where you've been so that back works. This capstone connects all three in one engine, run_player, which replays a list of button presses and reports the state of the player.

What you're building

run_player(playlist, commands)

playlist is the starting list of tracks, and commands is a list of button presses applied in order. The function returns a dict:

{
    "now": "X",                     # the track playing, or None
    "history": ["A"],               # the back stack, oldest first
    "up_next": ["B"],               # the queue, front first
    "playlist": ["A", "B", "C"],    # the linked playlist, in order
}

The state: three structures and a cursor

piece structure why this one
playlist linked list behind a dummy head tracks get spliced in right after the one playing, which is O(1) when you hold that node
cursor a reference to one playlist node how far playback has reached in the playlist; it starts on the dummy
up_next deque served from the front, and back adds to the front, so both ends need O(1)
history list used as a stack back always returns to the most recent track
now a title, or None nothing plays until the first next

The rules

  1. ["next"]: if something is playing, push it onto history. Then, if up_next has anything, play its front track. Otherwise move cursor one node along the playlist and play that node. If the playlist has run out, now becomes None and the cursor stays on the last node.
  2. ["back"]: if history is empty, do nothing. Otherwise put the track you were playing (if any) at the front of up_next, so the next next returns to it, and pop history into now.
  3. ["queue", title]: add title to the back of up_next.
  4. ["insert", title]: splice title into the playlist straight after cursor. If nothing has played from the playlist yet, the cursor is still on the dummy, so the track goes at the front.
  5. ["remove", title]: unlink the first playlist node named title, and skip the command if there isn't one. If that node is the cursor, the cursor steps back to the node before it. now keeps playing either way.

Any other command is ignored.

The queue goes first

The core of next is a priority rule: the queue always beats the playlist. Here's rule 1 on its own, with the playlist as a plain list and an index for now:

What does this print?
from collections import deque

up_next = deque(["X"])
playlist, pos = ["A", "B", "C"], -1
history, now = [], None
played = []
for _ in range(3):
    if now is not None:
        history.append(now)
    if up_next:
        now = up_next.popleft()
    else:
        pos += 1
        now = playlist[pos] if pos < len(playlist) else None
    played.append(now)
print(played)

Back without losing your place

Back pops the history. The track you leave shouldn't disappear, though, so rule 2 puts it in the queue, where next will find it. It has to go at the front of the queue:

broken — fix it

Pressing back and then next should return you to C, the track you left. It plays Encore instead.

Here the deque's two ends are both doing work: queue adds at the back, back adds at the front, and next always serves from the front.

Splicing at the cursor

cursor is a node you already hold, so insert is the O(1) splice from the linked-list lesson: cursor.next = Node(title, cursor.next). remove has to search by title, so it walks with a prev pointer. It also has to repair cursor if it just unlinked the node the cursor was on. Step through that case:

step through it
1class Node:
2 def __init__(self, val, next=None):
3 self.val = val
4 self.next = next
5 def __repr__(self):
6 return f"Node({self.val})"
7 
8dummy = Node(None, Node("A", Node("B", Node("C"))))
9cursor = dummy.next.next # playback has reached B
10prev = dummy
11while prev.next and prev.next.val != "B":
12 prev = prev.next
13if prev.next:
14 if prev.next is cursor:
15 cursor = prev # step back, so C still plays next
16 prev.next = prev.next.next
17print(cursor.val, cursor.next.val)

❓ Cross-question — "Why does the cursor step back instead of forward?" Because next always plays cursor.next. Stepping back to A keeps C as the next playlist track, which is what a listener expects after deleting the song under the cursor. Stepping forward to C would skip C.

❓ Cross-question — "Why doesn't remove touch now?" Deleting a song from a playlist doesn't stop it playing on your speakers. now is just a title, so the song carries on, and next continues from wherever the cursor is.

A class for the state, a function for the grader

A Player class keeps the five pieces of state together and gives each button a method. run_player is the boundary: it builds a Player, sends each command to the right method, and returns plain values. Here's the skeleton with the two simplest parts filled in:

python

Notice list(self.up_next) in state. It isn't decoration:

What does this print?
from collections import deque
up_next = deque(["B"])
print(up_next == ["B"])

Sending each command to its method reads best as a dict of handlers rather than a growing if/elif chain:

python

What each button costs

command cost why
next O(1) one popleft, or one hop along the chain
back O(1) a stack pop and an appendleft
queue O(1) a deque append
insert O(1) a splice after the node the cursor holds
remove O(n) finding the title walks the chain, though the unlink itself is O(1)
building the result O(n) one walk turns the chain back into a list

Idioms & real-world patterns

  • Commands as data. Button presses arrive as a list, so a whole session can be replayed, and the same commands always produce the same state. That's how the grader tests the engine, how Redux reducers and event-sourced systems work, and how you'd reproduce a user's bug report.
  • One class, one boundary function. State and behaviour live in Player. JSON goes in and comes out through run_player.
  • Pick each structure by where it's touched. The history is only touched at the top, the queue only at its ends, and the playlist at a node you hold. Each got the structure that makes its own operations O(1).

⚡ Advanced — repeat-all is a circular list

Point the last track's next back at the first and the playlist never runs out, so next loops forever. That's why the loop check from the linked-list lesson matters: a circular list is a feature in a music player and a bug almost everywhere else.

python

⚡ Advanced — making remove O(1)

Real players keep a dict from track id to node alongside a doubly linked list, the same pairing as the LRU cache from the linked-list lesson. The dict finds the node instantly and its prev arrow lets it unlink itself, so remove drops to O(1). Titles aren't unique, which is why real apps key that dict by a track id and not by title.


🎯 Your turn

Build run_player(playlist, commands) following the five rules above, and return the now / history / up_next / playlist dict:

  • run_player(["A", "B", "C"], [["next"], ["queue", "X"], ["next"], ["next"], ["back"]]){"now": "X", "history": ["A"], "up_next": ["B"], "playlist": ["A", "B", "C"]}

Trace it before you code it. The first next plays A. X is queued. The second next pushes A and plays X from the queue. The third pushes X and moves the cursor to B. Then back puts B at the front of the queue and pops X back into now.

Hint: start from the Player class above, add next, back, insert and remove methods, and have run_player send each command to one of them.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of the app is built. ✅

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upWhat plays next

The player's "Coming up" panel lists the next n tracks. The up-next queue always plays first, front to back, and then the playlist continues in order. Write upcoming(queue, playlist, n) returning those next n tracks, or fewer if there aren't enough. Don't change either input. upcoming(["X"], ["A", "B", "C"], 3)["X", "A", "B"].

queuefifo
DrillBack and forward

Tunebox's in-app browser has back and forward buttons. Write browse(start, actions) returning the page you end on. You start on start. ["open", page] visits a new page and clears everything you could have gone forward to. ["back"] and ["forward"] move one page, and do nothing if there's nowhere to go. browse("Home", [["open", "Jazz"], ["open", "Miles Davis"], ["back"]])"Jazz".

stacklifo
BuildA queue made of two stacks

Tunebox's sync log can only use stacks, but it must replay events in arrival order. Write stack_queue(ops) that simulates a FIFO queue using two lists used only with append and pop(). ["push", x] enqueues x, and ["pop"] dequeues. Return a list of every dequeued value in order, with None for a pop on an empty queue. stack_queue([["push", 1], ["push", 2], ["pop"], ["push", 3], ["pop"], ["pop"]])[1, 2, 3].

stackqueueamortized
BossThe offline cache

Tunebox keeps the capacity most recently played tracks downloaded for offline listening. Playing a cached track makes it the most recent. Playing a track that isn't cached when the cache is full evicts the least recently played one. Write lru_evictions(capacity, plays) returning the evicted tracks in order. capacity is at least 1. Build it from a doubly linked list plus a dict so every play is O(1). lru_evictions(2, ["A", "B", "A", "C", "B"])["B", "A"].

linked-listhash-mappointer-rewire
CapstonePlaylist edits with undo

Tunebox's playlist editor supports undo. Write edit_with_undo(tracks, ops), keeping the playlist as a linked list. ["insert", i, title] inserts title so it ends up at position i (valid when 0 <= i <= length). ["remove", i] removes the track at position i (valid when 0 <= i < length). Invalid edits are skipped and can't be undone. ["undo"] reverts the most recent edit that hasn't been undone yet, and does nothing if there isn't one. Return the final playlist. edit_with_undo(["A", "B"], [["insert", 1, "X"], ["remove", 0], ["undo"]])["A", "X", "B"].

linked-liststackpointer-rewire
run_player.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
run_player(playlist, commands) → dictThe player's state after every button press: now, history, up_next and playlist.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.