mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
290 lines
10 KiB
Rust
290 lines
10 KiB
Rust
//! The conformance suite every [`MediaPlayer`] must pass.
|
|
//!
|
|
//! One set of behaviours, run against every engine: `FakePlayer` and `MpvPlayer`
|
|
//! in `cargo test`, `ExoPlayerPlayer` instrumented on a device, `WebviewPlayer`
|
|
//! in vitest. A new engine is finished when it passes this.
|
|
//!
|
|
//! Written *before* the second engine on purpose. A suite written afterwards
|
|
//! encodes whatever the first engine happened to do, which is how three separate
|
|
//! playback implementations drifted apart in the first place.
|
|
//!
|
|
//! Each case names the defect it exists to prevent. Two of them —
|
|
//! [`opens_at_a_start_position`] and [`seek_while_opening_is_honoured`] — fail
|
|
//! against the pre-migration mpv path, which is what makes them a reproduction
|
|
//! of DR-241 rather than a restatement of it.
|
|
//!
|
|
//! Engines differ in *when* an open completes, so the suite drives that through
|
|
//! a [`Harness`] rather than sleeping: the fake completes on demand, mpv waits
|
|
//! for its `FileLoaded` event, ExoPlayer for `STATE_READY`.
|
|
//!
|
|
//! Available to `cargo test` and, behind the `conformance` feature, to the
|
|
//! `player-conformance` binary — so an engine that cannot run in-process
|
|
//! (ExoPlayer on a device) is driven by exactly the same cases rather than by a
|
|
//! second, drifting checklist.
|
|
//!
|
|
//! TRACES: UR-081 | DR-243 | UT-220
|
|
|
|
use std::time::Duration;
|
|
|
|
use super::media_player::{MediaPlayer, OpenRequest, Phase};
|
|
|
|
/// How the suite drives one engine.
|
|
pub trait Harness {
|
|
type Player: MediaPlayer;
|
|
|
|
fn player(&mut self) -> &mut Self::Player;
|
|
|
|
/// A request this engine can actually open, at `start`.
|
|
fn request(&self, start: Duration) -> OpenRequest;
|
|
|
|
/// Block until an in-flight `open` has finished (or failed).
|
|
///
|
|
/// The fake completes on demand; a real engine waits for its own readiness
|
|
/// event. Never a sleep — a timing-dependent suite is worse than none.
|
|
fn settle(&mut self);
|
|
|
|
/// Whether the engine is producing audio. Engines that cannot answer may
|
|
/// return `None`, which skips the silence assertions rather than passing
|
|
/// them vacuously.
|
|
fn audible(&mut self) -> Option<bool>;
|
|
|
|
/// How far a landed position may differ from the one asked for. Keyframe
|
|
/// granularity makes exactness the wrong bar for a real decoder.
|
|
fn seek_tolerance(&self) -> Duration {
|
|
Duration::from_secs(5)
|
|
}
|
|
|
|
/// Wait for a completed seek to be visible in `snapshot()`.
|
|
///
|
|
/// Engines differ in when that happens: one may record the target the
|
|
/// moment it accepts the seek, another may not report it until the decoder
|
|
/// has actually moved. Asserting immediately therefore passes on the first
|
|
/// and races on the second — which is precisely how this suite produced a
|
|
/// failure that came and went with machine load rather than with the code.
|
|
///
|
|
/// Default is a no-op, for engines whose snapshot is synchronous.
|
|
fn await_seek(&mut self, _target: Duration) {}
|
|
}
|
|
|
|
fn assert_near(actual: Duration, expected: Duration, tolerance: Duration, what: &str) {
|
|
let delta = actual.abs_diff(expected);
|
|
assert!(
|
|
delta <= tolerance,
|
|
"{what}: expected ~{expected:?}, got {actual:?} (tolerance {tolerance:?})"
|
|
);
|
|
}
|
|
|
|
/// Opening at zero reaches a usable state and starts near the beginning.
|
|
pub fn opens_from_the_beginning<H: Harness>(h: &mut H) {
|
|
let req = h.request(Duration::ZERO);
|
|
h.player().open(req).expect("open failed");
|
|
h.settle();
|
|
|
|
let s = h.player().snapshot();
|
|
assert!(
|
|
matches!(s.phase, Phase::Playing | Phase::Ready),
|
|
"after open the engine should hold media, phase was {:?}",
|
|
s.phase
|
|
);
|
|
assert_near(
|
|
s.position,
|
|
Duration::ZERO,
|
|
h.seek_tolerance(),
|
|
"start of item",
|
|
);
|
|
}
|
|
|
|
/// **DR-241.** Opening at a position starts *there*, not at zero.
|
|
///
|
|
/// The whole reason `OpenRequest` carries `start`. Under the previous contract a
|
|
/// caller had to `load()` then `seek()`, and because `loadfile` is asynchronous
|
|
/// the seek was issued against a player with nothing loaded, failed, and was
|
|
/// discarded — so resume and transcoded skip both played from the beginning.
|
|
pub fn opens_at_a_start_position<H: Harness>(h: &mut H) {
|
|
let start = Duration::from_secs(600);
|
|
let req = h.request(start);
|
|
h.player().open(req).expect("open failed");
|
|
h.settle();
|
|
|
|
let s = h.player().snapshot();
|
|
assert_ne!(
|
|
s.position,
|
|
Duration::ZERO,
|
|
"opened at {start:?} but playback began at zero - the start position was dropped"
|
|
);
|
|
assert_near(s.position, start, h.seek_tolerance(), "start position");
|
|
}
|
|
|
|
/// **DR-241.** A seek issued while opening is honoured, not lost.
|
|
///
|
|
/// The engine owns this window; no caller can avoid it, because a caller cannot
|
|
/// see when the pipeline becomes ready.
|
|
pub fn seek_while_opening_is_honoured<H: Harness>(h: &mut H) {
|
|
let target = Duration::from_secs(300);
|
|
let req = h.request(Duration::ZERO);
|
|
h.player().open(req).expect("open failed");
|
|
|
|
// Deliberately before settle(): this is the race, expressed on purpose.
|
|
h.player().seek(target).expect("seek during open failed");
|
|
h.settle();
|
|
|
|
let s = h.player().snapshot();
|
|
assert_near(
|
|
s.position,
|
|
target,
|
|
h.seek_tolerance(),
|
|
"seek issued while opening",
|
|
);
|
|
}
|
|
|
|
/// A later intent wins: the seek replaces the start position it overtook.
|
|
pub fn seek_while_opening_overrides_start<H: Harness>(h: &mut H) {
|
|
let start = Duration::from_secs(600);
|
|
let target = Duration::from_secs(120);
|
|
let req = h.request(start);
|
|
h.player().open(req).expect("open failed");
|
|
h.player().seek(target).expect("seek during open failed");
|
|
h.settle();
|
|
|
|
assert_near(
|
|
h.player().snapshot().position,
|
|
target,
|
|
h.seek_tolerance(),
|
|
"seek should override the start position it overtook",
|
|
);
|
|
}
|
|
|
|
/// Seeking a settled item lands where asked.
|
|
pub fn seeks_after_open<H: Harness>(h: &mut H) {
|
|
let req = h.request(Duration::ZERO);
|
|
h.player().open(req).expect("open failed");
|
|
h.settle();
|
|
|
|
let target = Duration::from_secs(420);
|
|
h.player().seek(target).expect("seek failed");
|
|
h.await_seek(target);
|
|
|
|
assert_near(
|
|
h.player().snapshot().position,
|
|
target,
|
|
h.seek_tolerance(),
|
|
"seek after open",
|
|
);
|
|
}
|
|
|
|
/// **DR-239.** Pause and play are reflected in the engine's own state.
|
|
///
|
|
/// An engine that changes nothing observable is indistinguishable from one that
|
|
/// ignored the call — which is exactly how a handler for mpv's `pause` property
|
|
/// sat unreachable while the UI waited for an event that never came.
|
|
pub fn pause_and_play_are_observable<H: Harness>(h: &mut H) {
|
|
let req = h.request(Duration::ZERO);
|
|
h.player().open(req).expect("open failed");
|
|
h.settle();
|
|
|
|
h.player().pause().expect("pause failed");
|
|
assert_eq!(
|
|
h.player().snapshot().phase,
|
|
Phase::Paused,
|
|
"pause must be visible in the snapshot"
|
|
);
|
|
if let Some(audible) = h.audible() {
|
|
assert!(!audible, "a paused engine must be silent");
|
|
}
|
|
|
|
h.player().play().expect("play failed");
|
|
assert_eq!(
|
|
h.player().snapshot().phase,
|
|
Phase::Playing,
|
|
"play must be visible in the snapshot"
|
|
);
|
|
}
|
|
|
|
/// `close()` reaches Idle, is silent, and can be called twice.
|
|
pub fn close_is_silent_and_idempotent<H: Harness>(h: &mut H) {
|
|
let req = h.request(Duration::ZERO);
|
|
h.player().open(req).expect("open failed");
|
|
h.settle();
|
|
|
|
h.player().close().expect("close failed");
|
|
assert_eq!(h.player().snapshot().phase, Phase::Idle);
|
|
if let Some(audible) = h.audible() {
|
|
assert!(!audible, "a closed engine must be silent");
|
|
}
|
|
|
|
h.player().close().expect("close must be idempotent");
|
|
assert_eq!(h.player().snapshot().phase, Phase::Idle);
|
|
}
|
|
|
|
/// Closing during an open must not let playback start afterwards.
|
|
///
|
|
/// The shape of the "audio keeps playing after leaving the player" report: an
|
|
/// open still in flight completed after the stop, and nothing was left to tell
|
|
/// it not to.
|
|
pub fn close_during_open_never_plays<H: Harness>(h: &mut H) {
|
|
let req = h.request(Duration::ZERO);
|
|
h.player().open(req).expect("open failed");
|
|
h.player().close().expect("close during open failed");
|
|
h.settle();
|
|
|
|
let s = h.player().snapshot();
|
|
assert!(
|
|
!s.phase.is_active(),
|
|
"an open cancelled by close must not start playing, phase was {:?}",
|
|
s.phase
|
|
);
|
|
if let Some(audible) = h.audible() {
|
|
assert!(!audible, "an engine closed during open must be silent");
|
|
}
|
|
}
|
|
|
|
/// Volume, mute and rate round-trip through the snapshot.
|
|
pub fn transport_settings_round_trip<H: Harness>(h: &mut H) {
|
|
let req = h.request(Duration::ZERO);
|
|
h.player().open(req).expect("open failed");
|
|
h.settle();
|
|
|
|
h.player().set_volume(0.25).expect("set_volume failed");
|
|
h.player().set_muted(true).expect("set_muted failed");
|
|
h.player().set_rate(1.5).expect("set_rate failed");
|
|
|
|
let s = h.player().snapshot();
|
|
assert!((s.volume - 0.25).abs() < 0.01, "volume did not round-trip");
|
|
assert!(s.muted, "mute did not round-trip");
|
|
assert!((s.rate - 1.5).abs() < 0.01, "rate did not round-trip");
|
|
}
|
|
|
|
/// Run every case against one engine.
|
|
///
|
|
/// Each case gets a fresh harness, because a suite whose cases depend on each
|
|
/// other's leftovers is one that hides state bugs instead of finding them.
|
|
#[macro_export]
|
|
macro_rules! media_player_conformance {
|
|
($name:ident, $make:expr) => {
|
|
mod $name {
|
|
use super::*;
|
|
use $crate::player::conformance as c;
|
|
|
|
macro_rules! case {
|
|
($case:ident) => {
|
|
#[test]
|
|
fn $case() {
|
|
let mut h = $make;
|
|
c::$case(&mut h);
|
|
}
|
|
};
|
|
}
|
|
|
|
case!(opens_from_the_beginning);
|
|
case!(opens_at_a_start_position);
|
|
case!(seek_while_opening_is_honoured);
|
|
case!(seek_while_opening_overrides_start);
|
|
case!(seeks_after_open);
|
|
case!(pause_and_play_are_observable);
|
|
case!(close_is_silent_and_idempotent);
|
|
case!(close_during_open_never_plays);
|
|
case!(transport_settings_round_trip);
|
|
}
|
|
};
|
|
}
|