Diagnosed on a device. The likeliest explanation for "audio keeps playing
after I leave the player", which is the report this line of work started from.
enter_background_audio and exit_background_audio are pure bookkeeping: a
boolean and a base offset. Neither confirms the audio stream opened, nor that
the webview <video> came back. exit_background_audio's own comment says the
element "becomes the player again once it reloads" — a future event nothing
waits for, while the flag calls the swap done the moment it is invoked.
Foreground the app, then leave the player before the element has reloaded, and
the stop is aimed at something that does not exist yet while the audio stream
keeps running. The mini player then adopts a live audio session, which is why a
movie reappears as an audio track and why it is intermittent.
Same defect class as DR-238 … DR-241: state asserted rather than confirmed. It
is what Phase::Opening and the open generation exist for — a handoff is an open
in flight, and a close during one must cancel it. Today the handoff never
reaches an engine as an open at all, which is why
close_during_open_never_plays passes on all four engines while the bug
survives.
Credit where due: the sequence came from the user reproducing it deliberately,
not from the logs.
DR-242 … DR-247 are in. The spec now says so rather than reading as a proposal
for work that already exists.
One deviation is recorded rather than quietly absorbed: DR-246 called for the
engines to own seek strategy outright, and they cannot — re-negotiating a
stream needs the repository, which sits above them. The engine declares the
ability and the caller acts on it. `determine_video_seek_strategy` therefore
survives, correctly typed over a declared capability instead of over a guess,
because the defect was its input rather than its existence.
A day of debugging Linux native video produced four defects (DR-238 … DR-241)
and one regression from fixing them in the wrong place. None of them were mpv
bugs. All four trace to the same missing seam.
`PlayerBackend` abstracts a *device* — load, then seek — rather than an
*intent*. A start position is therefore not expressible, so every caller
sequences load-then-seek itself and each races the engine's asynchronous load
independently. That is why resume worked through the adapter, which seeks
after "file loaded", and silently failed through the command, which seeks
immediately: two callers, one intent, two behaviours.
The same gap put transport rules above the engines. Whether a stream can be
seeked in place was decided by a truth table in a command handler, on behalf
of engines it does not own, which is how `use_html5` came to mean both "who
renders" and "how do I seek". And nothing in the contract obliged an engine to
report its own state, so a handler for mpv's `pause` property sat unreachable
while the UI waited for an event that never came.
Supporting evidence for the diagnosis: commands/player/mod.rs is 3,561 lines
and is where "stop → rebuild URL → update queue → load → seek" lives;
player_play_item needed a cfg(not(linux)) guard; and the frontend carries
didStartNativePlayback, didStopBackendEarly and hasPerformedInitialSeek —
playback state in the UI, which contradicts the one-directional rule.
The proposal is a MediaPlayer contract whose `open` carries the start
position, whose `seek` states a destination and leaves in-place-versus-re-open
to the engine, whose `snapshot` is one coherent read, and whose `Phase`
includes `Opening` — the state the previous design could not express and the
window a seek was lost in.
Testability is the half that makes it worth doing: one conformance suite run
against every engine, and a FakePlayer that lets the controller, queue,
autoplay and session logic be tested with no engine at all. The suite is
written before the second engine on purpose, so it cannot encode whatever the
first happened to do.
Migration is a strangler in eight steps; the first three are pure addition.