Building the basic framework

A player has three things to do: read the stream, decode, and put out picture and sound. None of the three sets its own pace — how fast you read depends on the disk and the network, how fast you decode on the codec and the machine, how fast you put out frames on the screen refresh. Run them in one thread and the whole thing can only move at the speed of the slowest; worse, local files show no problem at all, and the moment the address becomes a network URL it falls apart: a read has to wait on the server, and during that time nobody is decoding, so the picture sits empty.
So the first step is to cut the stream apart: reading is reading, decoding is decoding, picture is picture, sound is sound, one thread per segment, handing off through queues.
Once it is cut, a second problem appears: the thing doing the work in each segment is something that will be replaced later. Today data comes from ffmpeg, tomorrow a streaming protocol has to be wired in; today decoding is software, tomorrow it has to be hardware; today there is one picture output, tomorrow it has to go into another window. If those implementations are hard-wired at the call sites, every addition means one more branch in the body.
So what really has to be established first in an empty project is the two halves of the skeleton: how threads start, and where the parts come from.
Which classes in the player are a thread
In the player today, every class that starts a thread inherits the same base class, called RunTask. The base class holds just three things: a stop flag, a std::thread, and a thread name string; a subclass only fills in three hooks — what to do before the thread starts, what the loop body does, and what to do when stopping. How a thread starts and stops lives in one place, the base class; subclasses only mind their own loop.
The player itself is a class too, and the most special one. MediaPlayer is itself the command thread: it creates and releases tracks and render objects, and no other thread touches them. It is both a thread and the home of every playback object.

Everything on a light-blue background in the figure is a thread; grey is a container. The packet queue and the frame queue are both held by the track — the track is itself a container.
Every replaceable implementation hangs off the registry

The thread half answers "how do things run at the same time"; the other half is "where do the parts come from".
The player has to fetch data, decode, and produce picture; each of these has an interface, and each interface may have several implementations under it. If the call sites hard-wire an implementation, swapping one data-fetching leg or adding a hardware decoder means changing the player body. The answer the skeleton gives is a registry layer: capabilities register by interface, and callers ask for the interface, not the implementation.
The table does not store objects; it stores the function that knows how to make the object — the object is actually created at the moment it is used. This looks small, but it decides the cost of adding things: to register an implementation you touch no existing call site.
The tables come in two shapes, for two problems. The first is "one implementation per slot": IO sources for fetching data, video rendering, audio rendering, muxers — one stands in each slot; a lookup first asks "has anyone registered this slot", and if so it creates the object, if not it reports right there and goes no further. The second is "a chain of implementations per slot": decoders and encoders, where the same codec can hang several implementations, and registration order is candidate order — who is tried first and who it falls back to is decided by this table's sequence; that point unfolds in the chapter on output.
This figure is the registry's inventory: five one-per-slot tables (data-fetching legs, source objects, muxers, video rendering, audio rendering), four chain-per-slot tables (audio and video decoders and encoders), plus two capture device managers split by SDK. The entries not marked "plugin" are compiled in with the engine; the marked ones only appear when the module loads.
Registration timing matters too. Each implementation hands in its registration function when it is loaded, and the engine runs them all once at init; on cleanup it runs them again in reverse order, so a module can wrap up before it is unloaded. Plugins take this road: a plugin does not exist in the player at compile time at all; install it and it registers, and the registry grows a few entries. Plugin scanning is lazy — the directory is only scanned the first time a plugin table is queried. An app that only plays video, with no plugins installed, pays no startup cost for them.
One more thing is inconspicuous but has to be clear on day one: registry order is ABI. Once a plugin has been compiled against the registry's position in the manager, new tables can only be appended at the end; insert in the middle and an old plugin still accesses by the old position, lands on something else, and crashes outright. So "adding a table" here has to be settled together with registration timing.
Naming is skeleton work too: the name used for registration shows up all the way through options, logs, and error messages, so name it as the external thing it is. The name is the public face of this interface — what a data-fetching leg is called, what a decoder is called, callers know them only by that name. Change the name and configs and logs shift with it, so settle names carefully, and before renaming think through who is using it.
Once this layer stands, swapping IO schemes, adding hardware decoding, adding recording, wiring in plugins later all move on the registry side; the player body does not shift.
The logging framework
Once the skeleton stands, logging goes in next. There is a practical reason: nine out of ten player bugs are timing bugs — a frame comes out later than it should, a seek runs its steps in the wrong order — and such problems can almost never be reproduced on the spot; only the log can replay them. Debugging timing problems without logs is like repairing a watch with your eyes closed.
So a log line has to carry three things: a millisecond timestamp, an instance prefix, and thread attribution. The timestamp needs no explanation; timing problems live or die by it. The instance prefix handles two players running at once — the [MP0] at the start of a line is it. Thread attribution lets a log line reveal which thread and which track it came from.
There are only four levels: info, warn, error, debug, and no level switch — filtering is left to the host's observer. Messages are not built with format strings; variadic appends go all the way, with numbers, times, and frame objects each overloading their own way of being appended. Output goes through an observer callback, so the host writes files if it wants to and the engine never touches disk itself; with no observer attached it falls back to the console. The file leg is an observer the command-line tool attaches itself — one log per playback run in the working directory, which is especially handy when tracking something down.
Synchronous or asynchronous switches with the build profile: debug writes synchronously — when debugging, slow beats lost; release goes asynchronous — a dedicated log thread with a bounded ring queue, and when the queue is full the calling thread helps drain it on the spot; before the process exits there is a forced drain so the last few lines are not dropped.
The logging path must not throw, anywhere. During shutdown global objects may already be destructed; a log line at that point can only swallow its failure — lose a few lines, keep the process.
The avox engine is open source on GitHub: github.com/xxxzhou/avox, and this thread skeleton, registry, and logging framework are all in there.