Files
jellytau/src-tauri/src/player/media_player.rs
T
dtourolle 6b3d853442 feat(player): seek strategy follows what the engine says it can do
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.
2026-08-22 22:21:42 +02:00

265 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
}
}