The player's command queue and playback state machine

Chapter 1: The platform-independent player·about 2195 words

Every control action goes through the same queue

Four paths want to move the player's state: the UI thread pressing pause or dragging the progress bar; the read thread reporting the network dropped; the decode thread reporting it cannot keep up and needs to downgrade; the host querying progress or setting playback speed, from whatever calling thread. The four live on four threads, and if all of them could write directly, then when "current state" goes wrong nobody can say who changed it.

So the rule is: nobody changes it directly; whatever you want to do is packed into a command and pushed onto one queue. The queue has only a consumer — the command thread, one thread executing them one by one. open, close, seek, play, pause, setting speed, switching audio tracks, choosing subtitles all take this road. The command queue lives in the player object and is bounded; the command thread starts the moment the player object is created.

This step also lays down a deeper discipline: creating and destroying track objects and render objects happens only on the command thread. The reason is not complicated — destroying a video track means stopping the decode thread, clearing two queues, releasing GPU resources, and those actions cannot be repaired once they run concurrently with another command; the determinism bought by serialization is worth more than the little time concurrency saves.

The queue gives order and serialization: first in, first executed, no two commands changing state at once. But it guarantees order only, not completion — enqueue returns as soon as the queue has room, without waiting for the command to run; to learn the result you subscribe to state notifications or read the current state directly, and the only receipt in the whole chain is subtitle load and unload. One more: a successful enqueue does not mean the command will run; every command body opens with a condition check, and an untimely command — say pause sent before open — is discarded outright when it reaches the command thread. The queue guarantees order; the state machine judges at execution time whether the command is timely.

Only one thing can cut the line: closing playback. Before close is enqueued it clears the entire queue, then puts itself in — pending seeks and track switches are voided, and it does not wait behind anyone.

The nine states and the transitions between them

There are nine playback states in all: none, opening, ready, playing, pause, seek, buffering, stopped, completed. Counted one by one, the transitions they actually take are only these:

Commands entering the queue and the real transitions between states: the top half is command enqueue (a queue capped at 100 plus a single-consumer command thread), the bottom half is where states actually go — there are backward arrows and bypasses, not a straight line

  • none → opening → ready → playing: the startup path — open is accepted, IO opens and reports track info into ready, the first frame is rendered and the render thread enqueues a Play, into playing. If it cannot open, it jumps straight to stopped instead of waiting in between.
  • playing ⇄ pause and playing ⇄ buffering: the first is what the user presses; the second is entered only when rendering cannot get a frame and the packet queue is empty too, and returns to playing once the cache is sufficient.
  • playing / pause / buffering → seek → playing: seek is a transient state; entering and leaving happen in the same command, so whatever state it is entered from, it exits into playing.
  • ready → seek → playing: resume playback, jumping before playback has started; ready is the only non-playing state specially allowed to enter seek directly.
  • playing / pause / buffering → stopped (close, buffering watchdog timeout, open failure), playing / buffering → completed (tail packets played out).

Three points: first, playing, pause, and buffering form one union, and only inside it are commands like pause, seek, and buffering eligible to run; second, seek is a transient state shown to the outside, while pause and buffering are parallel intermediate states that both return to playing; third, there is no error state — errors do not go through state, they go through callbacks.

opening and ready: being able to get data and being able to produce picture

These two states separate two things: being able to get data, and being able to put out picture.

opening is the stretch where the command thread has accepted open and IO is opening: connecting the protocol, reading the container header, finding tracks. This stretch can fail — a failed IO open goes straight to stopped. The command thread also refuses to wait long while opening: its main loop normally has a short sleep at the end (to keep the CPU from spinning); whenever the queue holds a command, or the state is opening, that round skips the sleep and only yields its time slice, so the next command can always get through during this stretch.

ready originates in a callback on the IO side. IO hands over the track list and duration, and the player does three things: attach track descriptions to track objects — for audio only the currently selected track, extra tracks are dropped outright; record the total duration; enqueue a Ready command. Only when the command thread reaches it does the state become ready and the outside get notified, and then the tracks start — note the order: start the decode thread first, wait for decoding info to come back, then start the render thread.

So ready has a very specific meaning: track count, format, and duration are all queryable, the decode chain is attached, but there is not a single frame of picture — a window of "ready, but not yet content". It serves two purposes: one, resume playback — the seek is issued at the very moment of ready, which is why ready is the only non-playing state allowed to enter seek directly; two, it does not judge buffering, and goes straight to playing when the first frame arrives, otherwise it would report buffering right at ready and sit waiting for data.

buffering is reported by the render thread

The only entry point into buffering is on the render side: the render thread goes to get a frame and fails, and only then looks back at the two packet queues — only when both the audio and video packet queues are empty does it count as "there really is no data behind this". As long as the packet queues still hold stock, decoding is behind, not the stream broken, and it does not enter buffering. Also, only the playing state reports it.

The case where IO has already reported "end" branches: if both queues are empty it is time to wrap up, and Complete is enqueued; if IO has ended but tail packets are still playing through the pipeline (an audio tail can drag on for several seconds), that is neither a stall nor the end, and nothing is done.

On entering buffering, the command thread does three things. Stop rendering so the data in the queue stops being consumed; IO and the decoder keep running, storing up data. Anchor the clock to the currently displayed value and then pin it — without anchoring, the progress bar jumps backward at this stall; without pinning, it creeps forward by wall clock during the stall. Finally set buffering, and the watchdog resets to zero and starts timing.

Recovery happens on the command thread's tick, with three layers of conditions. The previous state must be playing — otherwise a buffering entered from pause would resume playback by itself. The queued cache duration for video or audio must be sufficient (called delayMs in the code). Above 4x speed only I frames are decoded, and the criterion switches to "there is a frame in the video frame queue". Also, if IO has reported end, recovery is unconditional so the tail packets play out; otherwise a seek landing less than two seconds from the end would never satisfy this gate and could only wait for the watchdog timeout.

If recovery never happens, the watchdog timeout closes the player outright, into stopped. The duration is relaxed for two kinds of source: torrent is a progressive-download source, and a seek landing in an undownloaded region has to wait for the data to arrive, so it is relaxed further; a direct http connection has the server's penalty window, so it is relaxed by one notch as well.

How pause and seek stop things: clock, IO, rendering

The pause command touches three places: stop the clock, stop IO, stop rendering. Stopping IO has two layers — first call out at the protocol layer, handing "stop sending, server" to protocols that support it (if not supported, treat it as if nothing happened), then pause its own read thread.

The decoder is not one of the three. Once rendering stops, the frame queue fills quickly, the decode thread jams at the "enqueue" step, the packet queue fills in turn, and the read thread is pushed back by the packet queue — on the IO side, besides the explicit stop call, queue depth itself is a brake. The next article, on the packet-to-frame path, shows the video frame queue is shallow and the audio one deep; the shallow one brakes quickly, which fits exactly here.

seek is much more troublesome because it has to move data. At enqueue it merges with the queue tail: if a seek is already pending, only its target position is changed, and the command thread executes only the last one. The real actions happen on the command thread, in this order: first give IO a heads-up that interrupts the read, then set the packet queue to closed and withdraw the backpressure — these two steps cannot be reversed; reversed, a fast source runs wild for a few packets first. Then three gates are slammed down together: packet queue closed, rendering paused, decode thread paused. Wait a moment for in-flight data to land in the queues, then clear both queues — without clearing, a stretch of old-position data plays after the jump. On the way, void the "IO ended" conclusion and clear pending subtitle events. Then tell IO to jump to the target position; if precise seek is on, arm each video track with "discard until the target", and only the first frame not earlier than the target resumes entering the queue. Finally the clock is set to the target position, and the three gates are released in reverse order.

Across the whole seek, the time spent in the seek state is just the short stretch where the command itself executes — entering and leaving happen in the same function body, waiting on no external event in between, never crossing the command boundary. But this state does not mean "jumping": the long-lived thing is another flag, set the moment seek is called and held until the real position catches up with the target (or a timeout covers it); while it is held, position queries return the jump target rather than the real position, so the UI does not first jump to the target and then flash back to the landing point. One exception needs care: the buffering state also allows seek, and that path does not go through the recovery branch in the tick, so nobody releases the clock's latch and the position would freeze at the target forever; therefore seek releases the latch once at its end, uniformly.

Errors take no state; they go through two callbacks

Errors have no dedicated state, because an error is not a state. An error arrives at the player first, which digests it once itself: what can be handled in place is handled in place, updating the state to where it should be along the way; then it calls back upward on two paths — IO errors go through onIoError with an error code and message; decode errors go through onDecodeError with the track type and result code. Whether to stop or retry is up to the layer above.

Source-side error codes come in tiers: negative values are fatal errors (network timeout, network closed, URL unsupported, device lost, decode lane dead), positive values can continue (end of file, invalid source, invalid data). End of file does not count as an error; the player routes it onto the "completed" path. The rest are passed up as-is, with the state unchanged.

Decode-side result codes distinguish timeout, unsupported, not found, open failure, missing config, and data error. One kind the player does not hand up: video decode timeout — the hardware-decoder-hung kind — it closes the player itself and converges on stopped.

"Digesting" has another meaning: once something can be judged dead, do not report it repeatedly. If a decode lane has never produced a frame and consecutive packet feeds all fail (this is what happens with AV1 in a build without software decoding), the source side judges the lane dead, stops feeding, releases the decoder, and reports it once as a source error, not once per packet.

The state machine closes things out on its own too: an open failure enters stopped, and a buffering watchdog timeout closes it itself.

The avox engine is open source on GitHub: github.com/xxxzhou/avox, and this command queue and state machine are both in there.