DR-246. The strategy used to turn on `is_hls` and `use_html5`, decided in a command handler on behalf of engines it does not own. That is how "who renders" came to mean "how do I seek", and why a transcoded seek silently did nothing the moment native video changed the renderer (DR-238). Engines now declare `Capabilities::seeks_transcoded_in_place` — true for hls.js, which seeks within the VOD playlist it was handed and lets the server catch up; false for mpv, whose HLS demuxer cannot make the server transcode from a new offset. The command asks whichever engine is rendering. Adding an engine no longer means editing a shared truth table. The item's transport is not read at the seek site any more; the compiler flagged it unused, which is the URL-shape input finally disappearing. A deviation from the spec, recorded deliberately: it called for the engine to own the decision outright. It cannot. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the ability and the caller acts on it. That still removes the defect — nobody guesses on another component's behalf — without pretending an engine can reach upward. Also fixes a latent race in the conformance suite, found by running it: the seek case asserted immediately, which passes on an engine that records the target when it accepts a seek and races on one that waits for the decoder to move. `Harness::await_seek` polls instead, the way the Android suite already did. It failed with machine load rather than with the code, which is the kind of test that teaches people to re-run until green. MpvPlayer 9/9 LegacyPlayer 8/9 - still only the mute/rate gap in the old trait 789 tests, clippy -D warnings clean with and without the feature.
265 lines
9.9 KiB
Rust
265 lines
9.9 KiB
Rust
//! 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;
|
||
use crate::settings::AudioSettings;
|
||
|
||
/// 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 *server-side transcode* can be seeked without re-opening the stream.
|
||
///
|
||
/// True for hls.js, which seeks within the VOD playlist it is handed and
|
||
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
|
||
/// the server transcode from a new offset.
|
||
///
|
||
/// Declared by the engine rather than inferred by the caller. The previous
|
||
/// design decided this from `is_hls` and `use_html5` in a command handler —
|
||
/// on behalf of engines it did not own — which is how "who renders" came to
|
||
/// mean "how do I seek" and why a transcoded seek silently did nothing the
|
||
/// moment native video changed the renderer (DR-238).
|
||
///
|
||
/// Re-negotiating a stream needs the repository, which sits above the
|
||
/// engine, so the engine states the capability and the caller acts on it.
|
||
pub seeks_transcoded_in_place: bool,
|
||
}
|
||
|
||
impl Capabilities {
|
||
/// What a native engine of this project's kind can do.
|
||
///
|
||
/// `seeks_transcoded_in_place` is false: both native engines decode the
|
||
/// stream themselves and neither can make the server transcode from a new
|
||
/// offset. hls.js is the exception, and says so for itself.
|
||
pub fn native() -> Self {
|
||
Self {
|
||
video: true,
|
||
audio_settings: true,
|
||
subtitle_switching: true,
|
||
audio_track_switching: true,
|
||
seeks_transcoded_in_place: false,
|
||
}
|
||
}
|
||
|
||
/// An engine that renders through the webview element, where hls.js seeks
|
||
/// within the playlist it was handed.
|
||
pub fn webview() -> Self {
|
||
Self {
|
||
video: true,
|
||
audio_settings: false,
|
||
subtitle_switching: true,
|
||
audio_track_switching: false,
|
||
seeks_transcoded_in_place: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// Apply EQ, normalisation and gapless settings.
|
||
///
|
||
/// Provided rather than required: engines that cannot honour them say so
|
||
/// through [`Capabilities::audio_settings`] and inherit this no-op, instead
|
||
/// of every implementation carrying an `Ok(())` it does not mean.
|
||
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
|
||
Ok(())
|
||
}
|
||
|
||
fn audio_settings(&self) -> AudioSettings {
|
||
AudioSettings::default()
|
||
}
|
||
}
|