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
["next"]: if something is playing, push it ontohistory. Then, ifup_nexthas anything, play its front track. Otherwise movecursorone node along the playlist and play that node. If the playlist has run out,nowbecomesNoneand the cursor stays on the last node.["back"]: ifhistoryis empty, do nothing. Otherwise put the track you were playing (if any) at the front ofup_next, so the nextnextreturns to it, and pophistoryintonow.["queue", title]: addtitleto the back ofup_next.["insert", title]: splicetitleinto the playlist straight aftercursor. If nothing has played from the playlist yet, the cursor is still on the dummy, so the track goes at the front.["remove", title]: unlink the first playlist node namedtitle, and skip the command if there isn't one. If that node is the cursor, the cursor steps back to the node before it.nowkeeps 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:
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:
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:
❓ Cross-question — "Why does the cursor step back instead of forward?" Because
nextalways playscursor.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
removetouchnow?" Deleting a song from a playlist doesn't stop it playing on your speakers.nowis just a title, so the song carries on, andnextcontinues 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:
Notice list(self.up_next) in state. It isn't decoration:
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:
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 throughrun_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.
⚡ 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. ✅
