# Spec: MediaPlayer — one controller API, three interchangeable engines **Status:** **Partially implemented.** DR-242 … DR-247 have shipped: the contract, `FakePlayer` and the conformance suite, `MpvPlayer`, the standalone runner, `LegacyPlayer`, the controller port, the capability-driven seek strategy, and ExoPlayer conformance on a device. What is left is DR-248 (the webview as an engine) and DR-249 (deleting `PlayerBackend` and the frontend playback-state flags). **Requirements:** UR-081 (new) → DR-242 … DR-249 (new); IR-034. Re-check `requirements.md` before allocating — ids moved several times while this was written. **UX spec:** n/a — no user-visible change is intended. That is the point. **Supersedes / revises:** absorbs `determine_video_seek_strategy` (`player/seek.rs`, DR-238) into the engines. Revises the backend half of [playback-backend-unification.md](playback-backend-unification.md). **Destination on completion:** [01-rust-backend.md](../architecture/01-rust-backend.md) — replaces the player state-machine section; and [05-platform-backends.md](../architecture/05-platform-backends.md) — the engines become implementations of a stated contract rather than three separate designs. ## Summary Replace the `PlayerBackend` trait with a `MediaPlayer` contract that expresses **intent** ("present this item, starting here") rather than **device operations** ("load", then "seek"). MPV, ExoPlayer and the webview element implement it; a `FakePlayer` implements it for tests; and one conformance suite runs against every implementation so a backend is either correct or visibly failing. No user-visible behaviour changes. What changes is that playback logic stops being written three times in the command layer. ## Motivation A day of debugging Linux native video produced four defects (DR-238 … DR-241). Every one of them traces to the same missing seam, not to mpv: | Defect | What it looked like | What it was | |---|---|---| | DR-241 | "Resume is broken", "I cannot skip" | `loadfile` is async, so a seek issued straight after a load fails and was discarded. The trait has no way to say *open at a position*, so every caller does load-then-seek and each races independently. | | DR-238 | Transcoded seeks silently did nothing | `use_html5` was doing double duty as "who renders" **and** "how do I seek", decided in the command layer by a truth table. | | DR-239 | Play/pause control never moved | `PropertyChange { name: "pause" }` was handled but never observed. Nothing in the contract required an engine to report its own state. | | DR-240 | Fullscreen left the picture at window size | `requestFullscreen()` moves the document; whoever owns the pixels has to be told separately. | The shape is consistent: **the same intent implemented in several places, each with its own timing and its own idea of the rules.** Resume worked through the adapter (which seeks after `File loaded`) and failed through the command (which seeks immediately). Two callers, one intent, two behaviours. Supporting evidence for the diagnosis: - `commands/player/mod.rs` is **3,561 lines** and is where "stop → rebuild URL → update queue → load → seek" lives. That is playback orchestration in the IPC layer. - `player_play_item` needed a `#[cfg(not(target_os = "linux"))]` guard, i.e. a platform decision in a command handler. - The frontend carries `didStartNativePlayback`, `didStopBackendEarly`, `hasPerformedInitialSeek`, `lastAppliedInitialPosition` — playback state in the UI, which contradicts the one-directional rule in CLAUDE.md. ### Why an abstraction, and not more fixes Each defect above was individually cheap to patch, and patching them is what produced a regression: routing transcoded seeks to a reload path turned "seek does nothing" into "seek jumps to zero", because the reload path's own seek was broken in the same way. **Symptom fixes in this area compound.** ## The background-audio handoff is an unconfirmed state swap Diagnosed on a device, 2026-08-23, and the likeliest explanation for "audio keeps playing after I leave the player" — the report this whole line of work started from. `enter_background_audio` and `exit_background_audio` in `PlayerController` are pure bookkeeping: they flip a boolean and set or clear a base offset. Neither confirms that the audio stream actually opened, nor that the webview `