Files
jellytau/src-tauri/src/player/legacy_player.rs
T
dtourolle 8904acb5f7 feat(player): run the old backend through the new contract
DR-245, first half. `LegacyPlayer` implements `MediaPlayer` over the existing
`PlayerBackend`, so engines not yet ported — ExoPlayer, the webview element,
the null backend — keep working while `PlayerController` moves across. Without
it the port would have to land all four engines at once.

It also makes the two designs comparable on one engine and one file. `open`
reproduces the old sequence faithfully: load, play, then seek for a start
position, with the seek's failure ignored exactly as callers used to ignore
it. Making it pass would defeat the point.

Running both engines over the same media is more informative than expected:

  MpvPlayer     9/9
  LegacyPlayer  8/9 - transport_settings_round_trip fails

Two things fall out of that. The start-position case now passes on *both*,
because DR-241 was fixed inside MpvBackend rather than only in the new engine
— so the suite confirms that fix independently, on a path it was not written
against. And the one genuine failure is a capability gap rather than a bug:
the old trait has no mute and no playback rate, so `LegacyPlayer` reports them
unsupported instead of folding mute into volume and losing the user's level.

That is the abstraction earning its keep on the first run: a missing
capability that was previously invisible is now a named, failing case.

The runner takes an engine argument:

    player-conformance <media-file> [mpv|legacy]
2026-08-22 21:23:39 +02:00

147 lines
5.1 KiB
Rust

//! A [`MediaPlayer`] over the old [`PlayerBackend`] trait.
//!
//! Two purposes.
//!
//! **Migration.** Engines not yet ported — ExoPlayer, the webview element, the
//! null backend — keep working while `PlayerController` moves onto the new
//! contract (DR-245). Without this the port would have to land all four engines
//! at once.
//!
//! **Evidence.** It reproduces exactly what every caller used to do: `load`,
//! then `play`, then `seek` for a start position. Running the conformance suite
//! against it therefore shows the old path failing the cases the new one passes,
//! on the same engine and the same media — which is the difference between
//! asserting that a design was wrong and demonstrating it.
//!
//! It is deliberately a faithful reproduction, not a fixed-up one. Making it
//! pass would defeat the point.
//!
//! TRACES: UR-081 | DR-245
#![allow(dead_code)] // Consumed when PlayerController is ported (DR-245).
use std::time::Duration;
use super::backend::{PlayerBackend, PlayerError};
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
use super::state::PlayerState;
pub struct LegacyPlayer<B: PlayerBackend> {
inner: B,
/// The old trait has no notion of "opening", so this is the best the wrapper
/// can do: it knows an item was handed over, not whether the engine is ready
/// for one. That gap is the whole problem.
has_item: bool,
}
impl<B: PlayerBackend> LegacyPlayer<B> {
pub fn new(inner: B) -> Self {
Self {
inner,
has_item: false,
}
}
pub fn inner_mut(&mut self) -> &mut B {
&mut self.inner
}
}
impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
/// Load, play, then seek — the sequence every caller used to write.
///
/// The seek is issued immediately, because a caller has no way to know when
/// the engine becomes ready. On an engine whose load is asynchronous it
/// fails and is discarded, and playback begins at zero: DR-241, reproduced.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
self.inner.load(&req.media)?;
self.has_item = true;
if req.autoplay {
self.inner.play()?;
}
if !req.start.is_zero() {
// Faithfully ignoring the failure, exactly as the old callers did.
let _ = self.inner.seek(req.start.as_secs_f64());
}
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.inner.play()
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.inner.pause()
}
fn close(&mut self) -> Result<(), PlayerError> {
self.has_item = false;
self.inner.stop()
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
self.inner.seek(to.as_secs_f64())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
self.inner.set_volume(volume)
}
/// The old trait has no mute. Folding it into volume would lose the user's
/// level, so this reports unsupported rather than pretending.
fn set_muted(&mut self, _muted: bool) -> Result<(), PlayerError> {
Err(PlayerError {
message: "mute is not supported by this backend".to_string(),
})
}
fn set_rate(&mut self, _rate: f64) -> Result<(), PlayerError> {
Err(PlayerError {
message: "playback rate is not supported by this backend".to_string(),
})
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_audio_track(index.unwrap_or(-1))
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_subtitle_track(index)
}
fn snapshot(&self) -> PlaybackSnapshot {
let phase = match self.inner.state() {
_ if !self.has_item => Phase::Idle,
PlayerState::Playing { .. } => Phase::Playing,
PlayerState::Paused { .. } => Phase::Paused,
PlayerState::Idle => Phase::Idle,
PlayerState::Error { error, .. } => Phase::Failed(error),
// `Loading` is the closest the old trait comes to an opening state,
// but it is set once the engine has accepted the item rather than
// while it is still accepting it — which is precisely the window it
// cannot describe.
PlayerState::Loading { .. } | PlayerState::Seeking { .. } => Phase::Ready,
};
PlaybackSnapshot {
phase,
position: Duration::from_secs_f64(self.inner.position().max(0.0)),
duration: self.inner.duration().map(Duration::from_secs_f64),
seekable: true,
volume: self.inner.volume(),
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
}
}
fn capabilities(&self) -> Capabilities {
Capabilities {
video: false,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
}
}
}