feat(player): the MediaPlayer contract
DR-242. Intent, not device operations. `open` carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load — the engine is the only layer that knows when its pipeline can accept a position, and it absorbs that internally by deferring or re-opening. `seek` states a destination and nothing else. Whether that is an in-place seek or a re-opened stream is the engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer cannot make a server transcode from a new offset. Callers stop guessing on behalf of engines they do not own. `snapshot` is one coherent read rather than a dozen getters, because reading position and duration separately is how a player reported <position> / 0.0 when a file unloaded between the two calls. `Phase::Opening` names the state the previous design could not express, and is the direct cause of DR-241: a seek arriving with nothing loaded had no phase to be queued against, so it was discarded. `Capabilities` exists so callers adapt without naming engines. If a caller ever branches on which engine it holds, this struct is missing something — engine identity leaking into callers is the coupling DR-238 came from. Nothing consumes it yet; PlayerController is ported in DR-245. Carries an explicit allow(dead_code) tied to that step rather than being hidden behind cfg(test), because it is production code being built in shippable pieces.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
//! The `MediaPlayer` contract: one API, interchangeable engines.
|
||||
//!
|
||||
//! See docs/specs/media-player-controller.md.
|
||||
//!
|
||||
//! This replaces [`PlayerBackend`](super::backend::PlayerBackend), which
|
||||
//! abstracts a *device* — `load`, then `seek` — rather than an *intent*. That
|
||||
//! distinction is not academic; it produced four shipped defects in one day:
|
||||
//!
|
||||
//! * A start position was not expressible, so every caller sequenced
|
||||
//! `load()` + `seek()` itself and each raced the engine's asynchronous load
|
||||
//! independently. Resume worked through one caller and silently failed through
|
||||
//! another (DR-241).
|
||||
//! * Whether a stream could be seeked in place was decided *above* the engines,
|
||||
//! by a truth table in a command handler, for engines it does not own (DR-238).
|
||||
//! * Nothing in the contract obliged an engine to report its own state, so a
|
||||
//! handler for mpv's `pause` property sat unreachable and the play/pause
|
||||
//! control never moved (DR-239).
|
||||
//!
|
||||
//! The contract below is written so each of those is a compile-time or
|
||||
//! conformance-time failure rather than a runtime surprise.
|
||||
//!
|
||||
//! TRACES: UR-081 | DR-242
|
||||
|
||||
// Scaffolding: nothing consumes this contract until `PlayerController` is
|
||||
// ported to it (DR-245). Kept out of `cfg(test)` deliberately — it is production
|
||||
// code being built in shippable steps, not a test fixture. Remove this allow
|
||||
// when the controller talks to `MediaPlayer`.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::backend::PlayerError;
|
||||
use super::media::MediaItem;
|
||||
use crate::repository::stream_selection::StreamSelection;
|
||||
|
||||
/// What an engine is doing right now.
|
||||
///
|
||||
/// `Opening` is the state the previous design could not express, and is the
|
||||
/// direct cause of DR-241: a seek that arrived while the engine had nothing
|
||||
/// loaded had no phase to be queued against, so it was simply discarded and
|
||||
/// playback began at zero.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// Nothing loaded. `close()` must reach this, and must be silent here.
|
||||
Idle,
|
||||
/// An `open` is in flight. Position is not yet meaningful; a `seek` arriving
|
||||
/// now must be honoured once the engine reaches `Ready`, never dropped.
|
||||
Opening,
|
||||
/// Loaded and able to play, but not advancing.
|
||||
Ready,
|
||||
Playing,
|
||||
Paused,
|
||||
/// Reached the end of the item by itself. Distinct from `Idle`, because
|
||||
/// autoplay cares which one happened.
|
||||
Ended,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
/// Whether the engine currently holds an item.
|
||||
pub fn has_media(&self) -> bool {
|
||||
!matches!(self, Phase::Idle | Phase::Failed(_))
|
||||
}
|
||||
|
||||
/// Whether playback is advancing.
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self, Phase::Playing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the UI consumes, read as one coherent value.
|
||||
///
|
||||
/// Deliberately a single snapshot rather than a dozen getters: reading position
|
||||
/// and duration through separate calls is how a paused player reported
|
||||
/// `<position> / 0.0` when a file unloaded between them.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlaybackSnapshot {
|
||||
pub phase: Phase,
|
||||
pub position: Duration,
|
||||
/// `None` while unknown — a live stream, or an item still opening.
|
||||
pub duration: Option<Duration>,
|
||||
/// Whether `seek` can be expected to land. False for live edges.
|
||||
pub seekable: bool,
|
||||
/// 0.0 – 1.0.
|
||||
pub volume: f32,
|
||||
pub muted: bool,
|
||||
pub rate: f64,
|
||||
pub audio_track: Option<i32>,
|
||||
pub subtitle_track: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for PlaybackSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: Phase::Idle,
|
||||
position: Duration::ZERO,
|
||||
duration: None,
|
||||
seekable: false,
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
rate: 1.0,
|
||||
audio_track: None,
|
||||
subtitle_track: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an engine can do, so callers adapt without naming engines.
|
||||
///
|
||||
/// If a caller ever branches on *which* engine it holds, this struct is missing
|
||||
/// something — add it here rather than sniffing. Engine identity leaking into
|
||||
/// callers is the coupling DR-238 came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Capabilities {
|
||||
/// The engine renders pictures, not only sound.
|
||||
pub video: bool,
|
||||
/// Audio settings (EQ, normalisation, gapless) are honoured.
|
||||
pub audio_settings: bool,
|
||||
/// Subtitle tracks can be selected without re-opening.
|
||||
pub subtitle_switching: bool,
|
||||
/// Audio tracks can be selected without re-opening.
|
||||
pub audio_track_switching: bool,
|
||||
}
|
||||
|
||||
/// A request to present an item.
|
||||
///
|
||||
/// `start` is the reason this type exists. Carrying it here — rather than
|
||||
/// leaving callers to `seek` after `open` — is what closes the load/seek race,
|
||||
/// because the engine is the only layer that knows when its pipeline can accept
|
||||
/// a position.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenRequest {
|
||||
pub media: MediaItem,
|
||||
pub selection: StreamSelection,
|
||||
/// Where to begin. `Duration::ZERO` means the start of the item.
|
||||
pub start: Duration,
|
||||
pub audio_track: Option<i32>,
|
||||
pub subtitle_track: Option<i32>,
|
||||
/// Begin playing as soon as the engine is able.
|
||||
pub autoplay: bool,
|
||||
}
|
||||
|
||||
impl OpenRequest {
|
||||
/// Open at the beginning, playing.
|
||||
pub fn new(media: MediaItem, selection: StreamSelection) -> Self {
|
||||
Self {
|
||||
media,
|
||||
selection,
|
||||
start: Duration::ZERO,
|
||||
audio_track: None,
|
||||
subtitle_track: None,
|
||||
autoplay: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn starting_at(mut self, start: Duration) -> Self {
|
||||
self.start = start;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that can present media.
|
||||
///
|
||||
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
|
||||
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
|
||||
/// them must pass [`super::conformance`].
|
||||
pub trait MediaPlayer: Send {
|
||||
/// Present `req.selection`, beginning at `req.start`.
|
||||
///
|
||||
/// One operation, deliberately. An engine that cannot start at an offset
|
||||
/// natively absorbs that internally — by deferring until loaded, or by
|
||||
/// re-opening — because it is the only layer that knows when it can.
|
||||
/// Callers must never follow `open` with a `seek` to achieve a start
|
||||
/// position; that is the bug this signature exists to prevent.
|
||||
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError>;
|
||||
fn pause(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Stop and release the current item.
|
||||
///
|
||||
/// Must be **idempotent** and must leave the engine **silent**. "Stopped"
|
||||
/// and "producing no audio" were not the same thing in the previous design,
|
||||
/// and the gap between them is audible.
|
||||
fn close(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Seek to an absolute position on the item's own timeline.
|
||||
///
|
||||
/// Whether that is an in-place seek or a re-open of the stream is the
|
||||
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
|
||||
/// cannot make a server transcode from a new offset. Callers state the
|
||||
/// destination and nothing else.
|
||||
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
||||
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>;
|
||||
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
|
||||
|
||||
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
|
||||
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
|
||||
|
||||
/// One coherent read of the engine's state.
|
||||
fn snapshot(&self) -> PlaybackSnapshot;
|
||||
|
||||
fn capabilities(&self) -> Capabilities;
|
||||
}
|
||||
Reference in New Issue
Block a user