feat(player): native video on Linux, and one contract for every player (v0.11.0)
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.
This commit is contained in:
@@ -249,6 +249,56 @@ impl PlayerBackend for NullBackend {
|
||||
}
|
||||
|
||||
// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
|
||||
/// Forward the trait through a box.
|
||||
///
|
||||
/// `Box<dyn PlayerBackend>` does not implement `PlayerBackend` on its own, so
|
||||
/// without this the boxed engine built at the composition root cannot be handed
|
||||
/// to anything generic over the trait — `LegacyPlayer` in particular.
|
||||
impl PlayerBackend for Box<dyn PlayerBackend> {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
(**self).load(media)
|
||||
}
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
(**self).play()
|
||||
}
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
(**self).pause()
|
||||
}
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
(**self).stop()
|
||||
}
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
(**self).seek(position)
|
||||
}
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
(**self).set_volume(volume)
|
||||
}
|
||||
fn position(&self) -> f64 {
|
||||
(**self).position()
|
||||
}
|
||||
fn duration(&self) -> Option<f64> {
|
||||
(**self).duration()
|
||||
}
|
||||
fn state(&self) -> PlayerState {
|
||||
(**self).state()
|
||||
}
|
||||
fn volume(&self) -> f32 {
|
||||
(**self).volume()
|
||||
}
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
(**self).set_audio_settings(settings)
|
||||
}
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
(**self).audio_settings()
|
||||
}
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
(**self).set_audio_track(stream_index)
|
||||
}
|
||||
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
(**self).set_subtitle_track(stream_index)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -377,6 +427,8 @@ mod tests {
|
||||
|
||||
// Create a test media item
|
||||
let media = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
@@ -436,6 +488,8 @@ mod tests {
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
@@ -489,6 +543,8 @@ mod tests {
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! A deterministic in-memory [`MediaPlayer`], for tests.
|
||||
//!
|
||||
//! Two jobs:
|
||||
//!
|
||||
//! 1. Give the conformance suite something that is correct by construction, so a
|
||||
//! failure there means the *suite* is wrong rather than an engine.
|
||||
//! 2. Let everything above the engine — controller, queue, autoplay, sleep
|
||||
//! timer, session — be tested with no mpv, no device and no network. Most of
|
||||
//! that logic is currently only reachable through a real engine, which is why
|
||||
//! so little of it is covered.
|
||||
//!
|
||||
//! It models the one behaviour that matters most: **opening is not
|
||||
//! instantaneous**. `open()` lands in [`Phase::Opening`] and stays there until
|
||||
//! [`FakePlayer::complete_open`] is called, so a test can put a `seek` into that
|
||||
//! window on purpose. That is the window DR-241 lived in.
|
||||
//!
|
||||
//! TRACES: UR-081 | DR-243
|
||||
|
||||
// `tick` and `fail_open` are for tests not yet written — the controller-level
|
||||
// ones DR-245 unlocks. Remove this allow once those exist.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::backend::PlayerError;
|
||||
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FakeEvent {
|
||||
Opened { url: String, start: Duration },
|
||||
Played,
|
||||
Paused,
|
||||
Closed,
|
||||
Sought(Duration),
|
||||
}
|
||||
|
||||
pub struct FakePlayer {
|
||||
snapshot: PlaybackSnapshot,
|
||||
/// Set while `Opening`; applied when the open completes.
|
||||
pending_start: Duration,
|
||||
/// A seek that arrived while opening. Honoured on completion, never dropped.
|
||||
deferred_seek: Option<Duration>,
|
||||
autoplay: bool,
|
||||
duration: Duration,
|
||||
/// Every call, in order — so tests can assert what an engine was *asked* to
|
||||
/// do, not only where it ended up.
|
||||
pub log: Vec<FakeEvent>,
|
||||
/// Whether audio is being produced. `close()` must clear it; the bug that
|
||||
/// motivated all this had a "stopped" player that was still audible.
|
||||
pub audible: bool,
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
|
||||
impl Default for FakePlayer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FakePlayer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
snapshot: PlaybackSnapshot::default(),
|
||||
pending_start: Duration::ZERO,
|
||||
deferred_seek: None,
|
||||
autoplay: true,
|
||||
duration: Duration::from_secs(3600),
|
||||
log: Vec::new(),
|
||||
audible: false,
|
||||
capabilities: Capabilities {
|
||||
video: true,
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
// The fake honours a seek in any phase, so it can claim this.
|
||||
seeks_transcoded_in_place: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The item this fake will report once opened.
|
||||
pub fn with_duration(mut self, duration: Duration) -> Self {
|
||||
self.duration = duration;
|
||||
self
|
||||
}
|
||||
|
||||
/// Finish an in-flight `open`, as a real engine's "file loaded" would.
|
||||
///
|
||||
/// Applies the requested start position, then any seek that arrived while
|
||||
/// opening — the later intent wins.
|
||||
pub fn complete_open(&mut self) {
|
||||
if self.snapshot.phase != Phase::Opening {
|
||||
return;
|
||||
}
|
||||
self.snapshot.duration = Some(self.duration);
|
||||
self.snapshot.seekable = true;
|
||||
self.snapshot.position = self.deferred_seek.take().unwrap_or(self.pending_start);
|
||||
if self.autoplay {
|
||||
self.snapshot.phase = Phase::Playing;
|
||||
self.audible = true;
|
||||
} else {
|
||||
self.snapshot.phase = Phase::Ready;
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance playback, for tests that care about time passing.
|
||||
pub fn tick(&mut self, by: Duration) {
|
||||
if self.snapshot.phase.is_active() {
|
||||
self.snapshot.position = (self.snapshot.position + by).min(self.duration);
|
||||
if self.snapshot.position >= self.duration {
|
||||
self.snapshot.phase = Phase::Ended;
|
||||
self.audible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_open(&mut self, why: &str) {
|
||||
self.snapshot.phase = Phase::Failed(why.to_string());
|
||||
self.audible = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaPlayer for FakePlayer {
|
||||
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
|
||||
self.log.push(FakeEvent::Opened {
|
||||
url: req.selection.url.clone(),
|
||||
start: req.start,
|
||||
});
|
||||
self.snapshot = PlaybackSnapshot {
|
||||
phase: Phase::Opening,
|
||||
volume: self.snapshot.volume,
|
||||
muted: self.snapshot.muted,
|
||||
rate: self.snapshot.rate,
|
||||
audio_track: req.audio_track,
|
||||
subtitle_track: req.subtitle_track,
|
||||
..PlaybackSnapshot::default()
|
||||
};
|
||||
self.pending_start = req.start;
|
||||
self.deferred_seek = None;
|
||||
self.autoplay = req.autoplay;
|
||||
self.audible = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
self.log.push(FakeEvent::Played);
|
||||
if self.snapshot.phase.has_media() {
|
||||
if self.snapshot.phase == Phase::Opening {
|
||||
self.autoplay = true;
|
||||
} else {
|
||||
self.snapshot.phase = Phase::Playing;
|
||||
self.audible = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
self.log.push(FakeEvent::Paused);
|
||||
if self.snapshot.phase == Phase::Opening {
|
||||
self.autoplay = false;
|
||||
} else if self.snapshot.phase.has_media() {
|
||||
self.snapshot.phase = Phase::Paused;
|
||||
self.audible = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close(&mut self) -> Result<(), PlayerError> {
|
||||
self.log.push(FakeEvent::Closed);
|
||||
self.snapshot = PlaybackSnapshot {
|
||||
volume: self.snapshot.volume,
|
||||
muted: self.snapshot.muted,
|
||||
rate: self.snapshot.rate,
|
||||
..PlaybackSnapshot::default()
|
||||
};
|
||||
self.pending_start = Duration::ZERO;
|
||||
self.deferred_seek = None;
|
||||
// An open that was still in flight must not come back to life.
|
||||
self.autoplay = false;
|
||||
self.audible = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
|
||||
self.log.push(FakeEvent::Sought(to));
|
||||
match self.snapshot.phase {
|
||||
// The window DR-241 lived in: hold it, do not discard it.
|
||||
Phase::Opening => self.deferred_seek = Some(to),
|
||||
Phase::Idle | Phase::Failed(_) => {
|
||||
return Err(PlayerError {
|
||||
message: "seek with nothing open".to_string(),
|
||||
})
|
||||
}
|
||||
_ => self.snapshot.position = to.min(self.duration),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
self.snapshot.volume = volume.clamp(0.0, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
|
||||
self.snapshot.muted = muted;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
|
||||
self.snapshot.rate = rate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
|
||||
self.snapshot.audio_track = index;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
|
||||
self.snapshot.subtitle_track = index;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> PlaybackSnapshot {
|
||||
self.snapshot.clone()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
self.capabilities
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `FakePlayer` runs the conformance suite.
|
||||
//!
|
||||
//! It is correct by construction, so a failure here means the *suite* is wrong,
|
||||
//! not an engine. That is what makes it safe to trust the same cases when they
|
||||
//! fail against a real one.
|
||||
//!
|
||||
//! TRACES: UR-081 | DR-243 | UT-220
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::conformance::Harness;
|
||||
use super::fake_player::FakePlayer;
|
||||
use super::media::MediaItem;
|
||||
use super::media_player::OpenRequest;
|
||||
use crate::repository::stream_selection::StreamSelection;
|
||||
|
||||
struct FakeHarness {
|
||||
player: FakePlayer,
|
||||
}
|
||||
|
||||
impl FakeHarness {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
player: FakePlayer::new().with_duration(Duration::from_secs(7200)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Harness for FakeHarness {
|
||||
type Player = FakePlayer;
|
||||
|
||||
fn player(&mut self) -> &mut FakePlayer {
|
||||
&mut self.player
|
||||
}
|
||||
|
||||
fn request(&self, start: Duration) -> OpenRequest {
|
||||
let selection = StreamSelection::local_file("http://example.invalid/stream.mp4");
|
||||
let media = MediaItem::sample("fake-item", &selection.url);
|
||||
OpenRequest::new(media, selection).starting_at(start)
|
||||
}
|
||||
|
||||
fn settle(&mut self) {
|
||||
self.player.complete_open();
|
||||
}
|
||||
|
||||
fn audible(&mut self) -> Option<bool> {
|
||||
Some(self.player.audible)
|
||||
}
|
||||
|
||||
/// Exact: the fake has no keyframes to round to, so any drift is a bug.
|
||||
fn seek_tolerance(&self) -> Duration {
|
||||
Duration::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
crate::media_player_conformance!(fake, FakeHarness::new());
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 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
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::media_player::{
|
||||
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
|
||||
};
|
||||
use super::state::PlayerState;
|
||||
|
||||
pub struct LegacyPlayer<B: PlayerBackend> {
|
||||
inner: B,
|
||||
/// Declared at construction: this wrapper is generic over engines with very
|
||||
/// different abilities, and only the composition root knows which one it
|
||||
/// just built. Guessing here would reintroduce exactly the inference DR-238
|
||||
/// removed.
|
||||
capabilities: Capabilities,
|
||||
/// 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, capabilities: Capabilities) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
capabilities,
|
||||
has_item: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(self.inner.position()).unwrap_or(Duration::ZERO),
|
||||
duration: self.inner.duration().and_then(duration_from_secs),
|
||||
seekable: true,
|
||||
volume: self.inner.volume(),
|
||||
muted: false,
|
||||
rate: 1.0,
|
||||
audio_track: None,
|
||||
subtitle_track: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_audio_settings(
|
||||
&mut self,
|
||||
settings: &crate::settings::AudioSettings,
|
||||
) -> Result<(), PlayerError> {
|
||||
self.inner.set_audio_settings(settings)
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> crate::settings::AudioSettings {
|
||||
self.inner.audio_settings()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
self.capabilities
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,18 @@ pub struct MediaItem {
|
||||
/// Whether the video requires server-side transcoding
|
||||
#[serde(default)]
|
||||
pub needs_transcoding: bool,
|
||||
/// How this item's stream is fetched, as the backend decided it.
|
||||
///
|
||||
/// Carried on the queue item so a later seek/reload does not have to guess.
|
||||
/// `None` for items queued by a path that never negotiated (audio tracks,
|
||||
/// direct URLs) and for anything queued before this field existed, where the
|
||||
/// caller falls back to `needs_transcoding` — every transcode this app
|
||||
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
||||
///
|
||||
/// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
|
||||
#[serde(default)]
|
||||
pub transport: Option<crate::repository::Transport>,
|
||||
|
||||
/// Video width in pixels
|
||||
#[serde(default)]
|
||||
pub video_width: Option<u32>,
|
||||
@@ -159,6 +171,19 @@ pub enum MediaSource {
|
||||
DirectUrl { url: String },
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
/// The URL or path an engine should open.
|
||||
///
|
||||
/// TRACES: UR-081 | DR-245
|
||||
pub fn playable_url(&self) -> String {
|
||||
match &self.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().into_owned(),
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
/// Get the Jellyfin item ID if available
|
||||
pub fn jellyfin_id(&self) -> Option<&str> {
|
||||
@@ -186,6 +211,48 @@ impl MediaItem {
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
/// A minimal item for tests.
|
||||
///
|
||||
/// The struct has twenty-odd fields, almost none of which any given test
|
||||
/// cares about, and repeating the literal per test is how a new field ends
|
||||
/// up added in thirty places. Set what matters on the result.
|
||||
///
|
||||
/// TRACES: UR-081 | DR-243
|
||||
#[cfg(any(test, feature = "conformance"))]
|
||||
pub fn sample(id: &str, url: &str) -> Self {
|
||||
Self {
|
||||
transport: None,
|
||||
id: id.to_string(),
|
||||
title: id.to_string(),
|
||||
name: None,
|
||||
artist: None,
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Video,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: url.to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -360,6 +427,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_creation_minimal() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-1".to_string(),
|
||||
title: "Test Item".to_string(),
|
||||
name: None,
|
||||
@@ -396,6 +465,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_jellyfin_id() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-2".to_string(),
|
||||
title: "Test".to_string(),
|
||||
name: None,
|
||||
@@ -431,6 +502,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_jellyfin_id_local() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-3".to_string(),
|
||||
title: "Local".to_string(),
|
||||
name: None,
|
||||
@@ -466,6 +539,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_jellyfin_id_direct_url() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-4".to_string(),
|
||||
title: "Direct".to_string(),
|
||||
name: None,
|
||||
@@ -508,6 +583,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-subs".to_string(),
|
||||
title: "With Subs".to_string(),
|
||||
name: None,
|
||||
@@ -543,6 +620,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_serialization() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "serial-item".to_string(),
|
||||
title: "Serial Test".to_string(),
|
||||
name: Some("Name".to_string()),
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! 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;
|
||||
|
||||
/// Seconds reported by an engine, as a `Duration`, without trusting the number.
|
||||
///
|
||||
/// `Duration::from_secs_f64` **panics** on a negative or non-finite value, and
|
||||
/// no engine promises otherwise. ExoPlayer reports `C.TIME_UNSET` —
|
||||
/// `Long::MIN_VALUE`, about -9.2e15 — for a stream whose length it does not
|
||||
/// know, which is every background-audio handoff: `/Audio/{id}/universal` is a
|
||||
/// chunked, length-less transcode.
|
||||
///
|
||||
/// Held as a float that junk was harmless. Converted to a `Duration` it became
|
||||
/// a panic that killed the backend mid-handoff and left a black screen with no
|
||||
/// controls. Every engine crossing into this contract goes through here.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-252
|
||||
pub fn duration_from_secs(seconds: f64) -> Option<Duration> {
|
||||
(seconds.is_finite() && seconds > 0.0).then(|| Duration::from_secs_f64(seconds))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// mpv.
|
||||
///
|
||||
/// Cannot seek a server-side transcode in place: its HLS demuxer will not
|
||||
/// make the server produce segments from a new offset, so the stream has to
|
||||
/// be re-opened.
|
||||
pub fn mpv() -> Self {
|
||||
Self {
|
||||
video: true,
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
seeks_transcoded_in_place: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// ExoPlayer.
|
||||
///
|
||||
/// **Can** seek a transcode in place. It is a full HLS client, so like
|
||||
/// hls.js it seeks within the VOD playlist it was handed and lets the
|
||||
/// server catch up. Grouping it with mpv as "a native engine" gets this
|
||||
/// exactly backwards — being native is not the property that matters here,
|
||||
/// speaking HLS is, and that is the whole reason this is declared per
|
||||
/// engine rather than inferred from a category.
|
||||
pub fn exoplayer() -> Self {
|
||||
Self {
|
||||
video: true,
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
seeks_transcoded_in_place: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The value that killed the backend: `C.TIME_UNSET` as seconds.
|
||||
///
|
||||
/// ExoPlayer reports it for any stream whose length it does not know, and
|
||||
/// `Duration::from_secs_f64` panics on it. A player must not be the place
|
||||
/// anyone discovers a float was strange.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-252 | UT-222
|
||||
#[test]
|
||||
fn test_junk_durations_do_not_panic() {
|
||||
// Long::MIN_VALUE milliseconds, as ExoPlayer hands it over.
|
||||
assert_eq!(duration_from_secs(-9_223_372_036_854_776.0), None);
|
||||
assert_eq!(duration_from_secs(-1.0), None);
|
||||
assert_eq!(duration_from_secs(0.0), None, "zero is not a duration");
|
||||
assert_eq!(duration_from_secs(f64::NAN), None);
|
||||
assert_eq!(duration_from_secs(f64::INFINITY), None);
|
||||
assert_eq!(duration_from_secs(f64::NEG_INFINITY), None);
|
||||
|
||||
// A real one still survives.
|
||||
assert_eq!(
|
||||
duration_from_secs(6997.024),
|
||||
Some(Duration::from_secs_f64(6997.024))
|
||||
);
|
||||
}
|
||||
}
|
||||
+187
-19
@@ -5,8 +5,18 @@
|
||||
pub mod autoplay;
|
||||
pub mod backend;
|
||||
pub mod background_policy;
|
||||
#[cfg(any(test, feature = "conformance"))]
|
||||
pub mod conformance;
|
||||
pub mod events;
|
||||
#[cfg(any(test, feature = "conformance"))]
|
||||
pub mod fake_player;
|
||||
#[cfg(test)]
|
||||
mod fake_player_conformance;
|
||||
pub mod legacy_player;
|
||||
pub mod media;
|
||||
pub mod media_player;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod mpv_player;
|
||||
pub mod queue;
|
||||
pub mod seek;
|
||||
pub mod session;
|
||||
@@ -24,16 +34,38 @@ pub mod android;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod mpv_backend;
|
||||
|
||||
/// Whether this process renders video natively — one answer, three consumers
|
||||
/// (UR-080 / DR-231, DR-235).
|
||||
pub mod native_video;
|
||||
|
||||
/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
|
||||
///
|
||||
/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
|
||||
/// everything here is the portable half, and Windows reuses it unchanged behind
|
||||
/// its own surface.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod mpv_render;
|
||||
|
||||
/// The native video surface mpv renders into (UR-080 / DR-231).
|
||||
///
|
||||
/// Linux-gated because the *surface* is GTK. Everything around it — the render
|
||||
/// context, its lifetime, frame pacing, the device profile — is not.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod video_surface;
|
||||
|
||||
// Platforms with no native audio backend (e.g. Windows) render audio-only
|
||||
// playback through a webview <audio> element, mirroring how all video renders.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
pub mod webview_audio_backend;
|
||||
|
||||
// Re-export commonly used types
|
||||
use crate::repository::stream_selection::StreamSelection;
|
||||
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
||||
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
||||
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
||||
pub use legacy_player::LegacyPlayer;
|
||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
|
||||
pub use media_player::{MediaPlayer, OpenRequest, Phase};
|
||||
pub use queue::{QueueManager, RepeatMode};
|
||||
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
||||
pub use session::{MediaSessionManager, MediaSessionType};
|
||||
@@ -208,7 +240,9 @@ use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Central player controller that coordinates playback
|
||||
pub struct PlayerController {
|
||||
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
||||
/// The engine. One contract, so the controller stops branching on which
|
||||
/// platform it is running on — see docs/specs/media-player-controller.md.
|
||||
backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
|
||||
queue: Arc<Mutex<QueueManager>>,
|
||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||
muted: bool,
|
||||
@@ -307,7 +341,7 @@ pub struct PlayerController {
|
||||
|
||||
impl PlayerController {
|
||||
pub fn new(
|
||||
backend: Box<dyn PlayerBackend>,
|
||||
backend: Box<dyn MediaPlayer>,
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
) -> Self {
|
||||
@@ -485,7 +519,12 @@ impl PlayerController {
|
||||
/// Used on platforms where video is rendered outside the native backend
|
||||
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
|
||||
/// item, but MPV must not start a redundant decode for it.
|
||||
#[cfg(target_os = "linux")]
|
||||
///
|
||||
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
|
||||
/// a runtime question — "does this renderer draw the picture?" — so the
|
||||
/// `else` arm is compiled on every platform even where it never runs. The
|
||||
/// gate outliving its caller broke the Android build outright, which went
|
||||
/// unnoticed because nothing built for Android afterwards.
|
||||
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||
debug!(
|
||||
"[PlayerController] set_current_item (no backend load): {}",
|
||||
@@ -538,8 +577,16 @@ impl PlayerController {
|
||||
*self.html5_playing.lock_safe() = None;
|
||||
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.load(item)?;
|
||||
backend.play()?;
|
||||
// One operation: the engine is handed the item and where to begin, so
|
||||
// there is no window between them for a position to be lost in.
|
||||
backend.open(OpenRequest::new(
|
||||
item.clone(),
|
||||
StreamSelection::for_queued_item(
|
||||
item.playable_url(),
|
||||
item.transport,
|
||||
item.needs_transcoding,
|
||||
),
|
||||
))?;
|
||||
drop(backend);
|
||||
|
||||
// A different item is loading; the last one's reported position must not
|
||||
@@ -713,7 +760,7 @@ impl PlayerController {
|
||||
return Ok(());
|
||||
}
|
||||
let mut backend = self.backend.lock_safe();
|
||||
if backend.state().is_playing() {
|
||||
if backend.snapshot().phase.is_active() {
|
||||
backend.pause()
|
||||
} else {
|
||||
backend.play()
|
||||
@@ -738,10 +785,32 @@ impl PlayerController {
|
||||
let position = self.absolute_position();
|
||||
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.stop()?;
|
||||
backend.close()?;
|
||||
drop(backend);
|
||||
self.clear_reported_time();
|
||||
|
||||
// Stopping means *nothing is playing*, from any renderer — not "the
|
||||
// thing we currently believe owns playback has been asked to stop".
|
||||
//
|
||||
// A background-audio handoff swaps which renderer that is, and the swap
|
||||
// is bookkeeping that can be mid-flight: `exit_background_audio` marks
|
||||
// the webview element the player again the moment it is called, while
|
||||
// the element has not reloaded yet. A stop aimed at what the flags say
|
||||
// is playing therefore misses the audio stream that actually is, and it
|
||||
// resurfaces in the mini player as an audio track.
|
||||
//
|
||||
// Clearing the handoff here is the other half of that: a stop that
|
||||
// leaves the base offset and the active flag behind lets the next
|
||||
// position read be interpreted against a handoff that no longer exists.
|
||||
//
|
||||
// TRACES: UR-040, UR-005 | DR-250
|
||||
if self.is_background_audio_active() {
|
||||
debug!("[PlayerController] stop: clearing an active background-audio handoff");
|
||||
}
|
||||
*self.background_audio_active.lock_safe() = false;
|
||||
self.set_background_audio_base(0.0);
|
||||
*self.html5_playing.lock_safe() = None;
|
||||
|
||||
if let Some(jellyfin_id) = jellyfin_id {
|
||||
self.report_stopped_at(jellyfin_id, position);
|
||||
}
|
||||
@@ -819,7 +888,7 @@ impl PlayerController {
|
||||
// If we're more than 3 seconds in, restart current track
|
||||
{
|
||||
let backend = self.backend.lock_safe();
|
||||
if backend.position() > 3.0 {
|
||||
if backend.snapshot().position.as_secs_f64() > 3.0 {
|
||||
debug!("[PlayerController] previous: restarting current track (position > 3s)");
|
||||
drop(backend);
|
||||
return self.seek(0.0);
|
||||
@@ -851,7 +920,7 @@ impl PlayerController {
|
||||
/// timeline and is what every caller outside the player itself means.
|
||||
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.seek(position)
|
||||
backend.seek(Duration::from_secs_f64(position.max(0.0)))
|
||||
}
|
||||
|
||||
/// Seek to an **absolute** position on the item's own timeline.
|
||||
@@ -902,23 +971,48 @@ impl PlayerController {
|
||||
/// Set the active audio track by stream index
|
||||
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.set_audio_track(stream_index)
|
||||
backend.select_audio_track(Some(stream_index))
|
||||
}
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.set_subtitle_track(stream_index)
|
||||
backend.select_subtitle_track(stream_index)
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub fn state(&self) -> PlayerState {
|
||||
self.backend.lock_safe().state()
|
||||
let phase = self.backend.lock_safe().snapshot().phase;
|
||||
let media = self.queue.lock_safe().current().cloned();
|
||||
match (phase, media) {
|
||||
(Phase::Playing, Some(media)) => PlayerState::Playing {
|
||||
media,
|
||||
position: self.position(),
|
||||
duration: self.duration().unwrap_or(0.0),
|
||||
},
|
||||
(Phase::Paused, Some(media)) => PlayerState::Paused {
|
||||
media,
|
||||
position: self.position(),
|
||||
duration: self.duration().unwrap_or(0.0),
|
||||
},
|
||||
(Phase::Opening, Some(media)) => PlayerState::Loading { media },
|
||||
(Phase::Failed(error), media) => PlayerState::Error { media, error },
|
||||
// Ready without an item, or anything terminal, reads as idle: the
|
||||
// queue is what says whether there is something to resume.
|
||||
_ => PlayerState::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the engine currently rendering can do.
|
||||
///
|
||||
/// TRACES: UR-081 | DR-246
|
||||
pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
|
||||
self.backend.lock_safe().capabilities()
|
||||
}
|
||||
|
||||
/// Get current position
|
||||
pub fn position(&self) -> f64 {
|
||||
self.backend.lock_safe().position()
|
||||
self.backend.lock_safe().snapshot().position.as_secs_f64()
|
||||
}
|
||||
|
||||
/// The position on the **item's own timeline**, whatever is rendering it.
|
||||
@@ -944,7 +1038,7 @@ impl PlayerController {
|
||||
///
|
||||
/// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
|
||||
pub fn absolute_position(&self) -> f64 {
|
||||
let native = self.backend.lock_safe().position().max(0.0);
|
||||
let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
|
||||
let reported = self.reported_time.lock_safe().last_position();
|
||||
let base = if self.is_background_audio_active() {
|
||||
*self.background_audio_base.lock_safe()
|
||||
@@ -1008,10 +1102,34 @@ impl PlayerController {
|
||||
///
|
||||
/// TRACES: UR-005 | DR-178
|
||||
pub fn duration(&self) -> Option<f64> {
|
||||
// Zero is not a duration, it is an engine saying it does not know yet.
|
||||
//
|
||||
// ExoPlayer reports `C.TIME_UNSET` until it has resolved one, and
|
||||
// `JellyTauPlayer.getDuration()` maps that to `0.0` — so the engine
|
||||
// answers `Some(0.0)`, every "unknown duration" fallback below is
|
||||
// skipped, and the seek bar is left with no scale. That presents as
|
||||
// scrubbing being broken rather than as a duration that never arrived.
|
||||
//
|
||||
// The item usually knows: the catalog carried a runtime long before
|
||||
// anything started decoding.
|
||||
//
|
||||
// TRACES: UR-005, UR-040 | DR-251
|
||||
let usable = |d: f64| (d > 0.0).then_some(d);
|
||||
|
||||
self.backend
|
||||
.lock_safe()
|
||||
.duration()
|
||||
.or_else(|| self.observed_duration())
|
||||
.snapshot()
|
||||
.duration
|
||||
.map(|d| d.as_secs_f64())
|
||||
.and_then(usable)
|
||||
.or_else(|| self.observed_duration().and_then(usable))
|
||||
.or_else(|| {
|
||||
self.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.and_then(|item| item.duration)
|
||||
.and_then(usable)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get queue reference
|
||||
@@ -1066,7 +1184,7 @@ impl PlayerController {
|
||||
|
||||
/// Get current volume (0.0 - 1.0)
|
||||
pub fn volume(&self) -> f32 {
|
||||
self.backend.lock_safe().volume()
|
||||
self.backend.lock_safe().snapshot().volume
|
||||
}
|
||||
|
||||
/// Check if muted
|
||||
@@ -1167,7 +1285,7 @@ impl PlayerController {
|
||||
drop(timer);
|
||||
|
||||
// Stop the backend
|
||||
if let Err(e) = backend.lock_safe().stop() {
|
||||
if let Err(e) = backend.lock_safe().close() {
|
||||
error!("[SleepTimer] Failed to stop playback: {}", e);
|
||||
}
|
||||
continue;
|
||||
@@ -1897,6 +2015,8 @@ impl PlayerController {
|
||||
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
|
||||
|
||||
let media_item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: next.id.clone(),
|
||||
title: next.name.clone(),
|
||||
name: Some(next.name.clone()),
|
||||
@@ -2180,7 +2300,10 @@ impl Default for PlayerController {
|
||||
let playback_reporter = Arc::new(TokioMutex::new(None));
|
||||
let position_throttler = Arc::new(EventThrottler::new());
|
||||
Self::new(
|
||||
Box::new(NullBackend::new()),
|
||||
Box::new(LegacyPlayer::new(
|
||||
NullBackend::new(),
|
||||
crate::player::media_player::Capabilities::mpv(),
|
||||
)),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
)
|
||||
@@ -2189,6 +2312,35 @@ impl Default for PlayerController {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// A duration the engine does not know must fall back to the one the item
|
||||
/// carries, and zero must count as "does not know".
|
||||
///
|
||||
/// ExoPlayer reports `C.TIME_UNSET` for a duration it has not resolved;
|
||||
/// `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answers
|
||||
/// `Some(0.0)` rather than `None` and every "unknown duration" fallback is
|
||||
/// skipped. The seek bar then has no scale, which presents as scrubbing
|
||||
/// being dead rather than as a missing duration.
|
||||
///
|
||||
/// TRACES: UR-005, UR-040 | DR-251 | UT-221
|
||||
#[test]
|
||||
fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
|
||||
let controller = PlayerController::default();
|
||||
let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
|
||||
item.duration = Some(1800.0);
|
||||
|
||||
{
|
||||
let queue_arc = controller.queue();
|
||||
let mut queue = queue_arc.lock_safe();
|
||||
queue.set_queue(vec![item], 0);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
controller.duration(),
|
||||
Some(1800.0),
|
||||
"an engine that cannot report a duration should not erase the one the item carries"
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Test emitter that captures events for asserting the HTML5 report methods
|
||||
@@ -2580,6 +2732,8 @@ mod tests {
|
||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||
(0..count)
|
||||
.map(|i| MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: format!("item_{}", i),
|
||||
title: format!("Track {}", i + 1),
|
||||
name: Some(format!("Track {}", i + 1)),
|
||||
@@ -3792,6 +3946,8 @@ mod tests {
|
||||
|
||||
// Queue holds the episode that just finished playing
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
media_type: MediaType::Video,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep1.mkv".to_string(),
|
||||
@@ -3827,6 +3983,8 @@ mod tests {
|
||||
|
||||
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio, // audio-only handoff, not Video
|
||||
series_id: Some("series1".to_string()),
|
||||
@@ -3943,6 +4101,8 @@ mod tests {
|
||||
|
||||
// Currently playing: ep2 handed off to audio-only background playback.
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -3978,6 +4138,8 @@ mod tests {
|
||||
/// URL carrying the handoff position.
|
||||
fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -3998,6 +4160,8 @@ mod tests {
|
||||
/// handoff point.
|
||||
fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
source: MediaSource::Local {
|
||||
file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
|
||||
jellyfin_item_id: Some("ep2".to_string()),
|
||||
@@ -4682,6 +4846,8 @@ mod tests {
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Video,
|
||||
@@ -4717,6 +4883,8 @@ mod tests {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
media_type: MediaType::Video,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep1.mkv".to_string(),
|
||||
|
||||
@@ -34,6 +34,19 @@ pub struct MpvBackend {
|
||||
/// through reported 0.0 / unknown exactly when end-of-file handling needed to
|
||||
/// know where playback reached. See [`ObservedTime`].
|
||||
observed: Arc<Mutex<ObservedTime>>,
|
||||
/// A seek that arrived before MPV had a file to seek in.
|
||||
///
|
||||
/// `loadfile` is asynchronous: it returns as soon as the command is queued,
|
||||
/// so `time-pos` is not yet a resolvable property and setting it fails. A
|
||||
/// seek issued in that window used to be dropped on the floor, and the two
|
||||
/// callers that do exactly this are the ones a viewer notices — resume, and
|
||||
/// a transcoded seek, both of which re-open the stream and then ask for a
|
||||
/// position. The stream reloaded and played from zero.
|
||||
///
|
||||
/// Held here and applied by the `FileLoaded` arm.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-241
|
||||
pending_seek: Arc<Mutex<Option<f64>>>,
|
||||
}
|
||||
|
||||
struct InternalState {
|
||||
@@ -89,6 +102,32 @@ fn get_stream_url(media: &MediaItem) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mpv handle of the backend this process created, for the video surface.
|
||||
///
|
||||
/// A `OnceLock` rather than a field reached through `PlayerBackend`, because the
|
||||
/// trait is cross-platform and a raw mpv pointer is not something every backend
|
||||
/// should have to pretend to have. Stored as `usize` because a raw pointer is
|
||||
/// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is
|
||||
/// also where mpv was created.
|
||||
///
|
||||
/// Written once at construction and never cleared: the backend outlives the
|
||||
/// window, so there is no window in which this could dangle while a surface is
|
||||
/// still using it.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231
|
||||
static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
|
||||
/// The registered handle, or null if no MPV backend was created (initialisation
|
||||
/// can fail, and the app falls back to a no-op backend rather than dying).
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231
|
||||
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
|
||||
MPV_HANDLE
|
||||
.get()
|
||||
.map(|p| *p as *mut libmpv_sys::mpv_handle)
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
impl MpvBackend {
|
||||
/// Create a new MPV backend
|
||||
pub fn new(
|
||||
@@ -137,9 +176,28 @@ impl MpvBackend {
|
||||
message: format!("Failed to configure MPV audio-display: {:?}", e),
|
||||
})?;
|
||||
|
||||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
// Video is disabled unless this process is drawing it.
|
||||
//
|
||||
// `video: no` is why mpv has never decoded a frame here: Linux video has
|
||||
// always gone through the webview, and decoding it twice would burn a
|
||||
// core for a picture nobody sees. With native video on, mpv needs both
|
||||
// the decoder *and* `vo=libmpv` — the render API only works through that
|
||||
// output, and the default would try to open a window of its own.
|
||||
//
|
||||
// Set at construction because mpv resolves the video output when it
|
||||
// initialises; flipping it later does not re-open one.
|
||||
//
|
||||
// TRACES: UR-080 | DR-231, DR-235
|
||||
if super::native_video::enabled() {
|
||||
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
|
||||
message: format!("Failed to select the libmpv video output: {:?}", e),
|
||||
})?;
|
||||
info!("[MpvBackend] native video enabled (vo=libmpv)");
|
||||
} else {
|
||||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
}
|
||||
|
||||
// Set volume to 100% (we'll control via MPV's volume property)
|
||||
mpv.set_property("volume", 100i64)
|
||||
@@ -178,13 +236,21 @@ impl MpvBackend {
|
||||
}));
|
||||
|
||||
let backend = MpvBackend {
|
||||
mpv: Arc::new(mpv),
|
||||
mpv: {
|
||||
let mpv = Arc::new(mpv);
|
||||
// Publish the handle for the video surface (DR-231). Ignores a
|
||||
// second call: only one MPV backend is ever constructed, and a
|
||||
// failed re-init must not replace a live handle.
|
||||
let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize);
|
||||
mpv
|
||||
},
|
||||
state,
|
||||
event_emitter,
|
||||
audio_settings: AudioSettings::default(),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
last_seek_time: Arc::new(AtomicU64::new(0)),
|
||||
pending_seek: Arc::new(Mutex::new(None)),
|
||||
observed: Arc::new(Mutex::new(ObservedTime::default())),
|
||||
};
|
||||
|
||||
@@ -202,6 +268,7 @@ impl MpvBackend {
|
||||
let state = self.state.clone();
|
||||
let reporter = self.playback_reporter.clone();
|
||||
let throttler = self.position_throttler.clone();
|
||||
let pending_seek_for_events = self.pending_seek.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
info!("[MpvBackend] Event loop started");
|
||||
@@ -211,6 +278,30 @@ impl MpvBackend {
|
||||
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
|
||||
});
|
||||
|
||||
// libmpv delivers PropertyChange only for properties registered
|
||||
// here. Every name matched in the loop below needs a line in this
|
||||
// block or its handler is unreachable — an omission that reads as
|
||||
// working code, because the handler is sitting right there.
|
||||
// UT-218 holds the two lists together.
|
||||
//
|
||||
// `pause` drives the play/pause control: the UI consumes
|
||||
// StateChanged rather than tracking playback itself, per the
|
||||
// one-directional state rule. Unobserved, the event never came and
|
||||
// the button never moved. Invisible until native video shipped,
|
||||
// because the webview <video> element's own DOM events drove that
|
||||
// control on Linux.
|
||||
//
|
||||
// TRACES: UR-005 | DR-239
|
||||
ev_ctx
|
||||
.observe_property("pause", libmpv::Format::Flag, 0)
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
"[MpvBackend] Failed to observe 'pause': {:?} — the play/pause \
|
||||
control will not follow the player",
|
||||
e
|
||||
);
|
||||
});
|
||||
|
||||
loop {
|
||||
match ev_ctx.wait_event(1.0) {
|
||||
Some(Ok(event)) => match event {
|
||||
@@ -220,6 +311,43 @@ impl MpvBackend {
|
||||
libmpv::events::Event::FileLoaded => {
|
||||
info!("[MpvBackend] File loaded");
|
||||
|
||||
// Apply a seek that arrived while there was nothing
|
||||
// to seek in. TRACES: UR-040, UR-005 | DR-241
|
||||
{
|
||||
let target = pending_seek_for_events.lock_safe().take();
|
||||
if let Some(position) = target {
|
||||
match mpv.set_property("time-pos", position) {
|
||||
Ok(()) => info!(
|
||||
"[MpvBackend] applied deferred seek to {position}"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"[MpvBackend] deferred seek to {position} failed: {:?}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Geometry, so "the picture does not fill the screen"
|
||||
// can be attributed rather than guessed at. `width`/
|
||||
// `height` are the decoded frame; `dwidth`/`dheight`
|
||||
// are what mpv will *display* after aspect
|
||||
// correction. A file that carries its letterbox
|
||||
// baked into the picture reports a 16:9 dwidth and
|
||||
// is then pillarboxed on a wider panel — which looks
|
||||
// identical to a rendering bug from outside.
|
||||
{
|
||||
let n = |k: &str| mpv.get_property::<i64>(k).unwrap_or(-1);
|
||||
info!(
|
||||
"[MpvBackend] video geometry: {}x{} decoded, {}x{} display, aspect {:?}",
|
||||
n("width"),
|
||||
n("height"),
|
||||
n("dwidth"),
|
||||
n("dheight"),
|
||||
mpv.get_property::<f64>("video-params/aspect").ok(),
|
||||
);
|
||||
}
|
||||
|
||||
// Get duration
|
||||
if let Ok(duration) = mpv.get_property::<f64>("duration") {
|
||||
if let Some(emitter) = &event_emitter {
|
||||
@@ -522,11 +650,24 @@ impl PlayerBackend for MpvBackend {
|
||||
.as_millis() as u64;
|
||||
self.last_seek_time.store(now, Ordering::Relaxed);
|
||||
|
||||
self.mpv
|
||||
.set_property("time-pos", position)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to seek: {:?}", e),
|
||||
})?;
|
||||
// `time-pos` only resolves while a file is loaded. `loadfile` is
|
||||
// asynchronous, so a seek issued straight after a reload — resume, or a
|
||||
// transcoded seek — lands in a window where this fails, and dropping it
|
||||
// there is what makes the stream play from zero instead of the position
|
||||
// that was asked for. Hold it and let `FileLoaded` apply it.
|
||||
// TRACES: UR-040, UR-005 | DR-241
|
||||
if let Err(e) = self.mpv.set_property("time-pos", position) {
|
||||
debug!(
|
||||
"[MpvBackend] seek to {position} deferred until the file loads ({:?})",
|
||||
e
|
||||
);
|
||||
*self.pending_seek.lock_safe() = Some(position);
|
||||
self.observed.lock_safe().record_position(position);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// A seek that lands clears any earlier deferred one: the newer intent wins.
|
||||
*self.pending_seek.lock_safe() = None;
|
||||
|
||||
// The poll thread suppresses updates for 150ms after a seek, so without
|
||||
// this a file ending inside that window would report the pre-seek time.
|
||||
|
||||
@@ -13,6 +13,52 @@ mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// Every property the event loop *handles* must also be *observed*.
|
||||
///
|
||||
/// libmpv only delivers `PropertyChange` for properties registered with
|
||||
/// `mpv_observe_property`. A `match` arm for an unobserved property is
|
||||
/// unreachable code that looks exactly like working code: the handler is
|
||||
/// right there, so the behaviour reads as implemented.
|
||||
///
|
||||
/// This cost a real bug. `pause` was handled and never observed, so
|
||||
/// `StateChanged` was never emitted on pause or resume. It stayed invisible
|
||||
/// while Linux video played in the webview, because the `<video>` element's
|
||||
/// own DOM events drove the play/pause control; turning native video on made
|
||||
/// the UI depend on the event that never came, and the button stopped
|
||||
/// responding.
|
||||
///
|
||||
/// Asserted against the source because there is no way to observe the
|
||||
/// registration at runtime without a live mpv instance.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-239 | UT-218
|
||||
#[test]
|
||||
fn test_every_handled_property_is_observed() {
|
||||
let src = include_str!("mpv_backend.rs");
|
||||
|
||||
let handled: Vec<&str> = src
|
||||
.match_indices("PropertyChange { name: \"")
|
||||
.filter_map(|(i, m)| {
|
||||
let rest = &src[i + m.len()..];
|
||||
rest.find('"').map(|end| &rest[..end])
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!handled.is_empty(),
|
||||
"no PropertyChange arms found - has the event loop been restructured?"
|
||||
);
|
||||
|
||||
for name in handled {
|
||||
let observed = format!("observe_property(\"{name}\"");
|
||||
assert!(
|
||||
src.contains(&observed),
|
||||
"mpv_backend.rs handles PropertyChange for {name:?} but never calls \
|
||||
observe_property({name:?}, ..). libmpv will never deliver that event, \
|
||||
so the handler is dead code."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that simulates the position update thread spawning async tasks
|
||||
/// without a Tokio runtime (the bug we just fixed)
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! [`MediaPlayer`] over libmpv.
|
||||
//!
|
||||
//! The point of difference from `MpvBackend` is [`MpvPlayer::open`]: the start
|
||||
//! position is applied **at load time**, via mpv's own `start` option, instead
|
||||
//! of being seeked to afterwards. `loadfile` is asynchronous, so a seek issued
|
||||
//! after it targets a player that has nothing loaded, fails, and — under the old
|
||||
//! contract — was discarded. That is DR-241, and it is why resume and transcoded
|
||||
//! skip both played from zero.
|
||||
//!
|
||||
//! A seek arriving during [`Phase::Opening`] is held and applied when the file
|
||||
//! loads, so no caller has to know where that window begins or ends.
|
||||
//!
|
||||
//! TRACES: UR-081, UR-040, UR-005 | DR-244
|
||||
|
||||
#![allow(dead_code)] // Wired to PlayerController in DR-245.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use libmpv::Mpv;
|
||||
use log::{debug, info, warn};
|
||||
|
||||
use super::backend::PlayerError;
|
||||
use super::media_player::{
|
||||
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
|
||||
};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// State the event thread writes and the caller reads.
|
||||
#[derive(Debug)]
|
||||
struct Shared {
|
||||
phase: Phase,
|
||||
position: Duration,
|
||||
duration: Option<Duration>,
|
||||
seekable: bool,
|
||||
/// A seek that arrived while opening. Applied on `FileLoaded`.
|
||||
deferred_seek: Option<Duration>,
|
||||
/// Cleared by `close()`, so an open still in flight cannot come back to life
|
||||
/// and start playing after the caller has stopped it.
|
||||
open_generation: u64,
|
||||
}
|
||||
|
||||
impl Default for Shared {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: Phase::Idle,
|
||||
position: Duration::ZERO,
|
||||
duration: None,
|
||||
seekable: false,
|
||||
deferred_seek: None,
|
||||
open_generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MpvPlayer {
|
||||
mpv: Arc<Mpv>,
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
volume: f32,
|
||||
muted: bool,
|
||||
rate: f64,
|
||||
audio_track: Option<i32>,
|
||||
subtitle_track: Option<i32>,
|
||||
}
|
||||
|
||||
/// How the engine should talk to the machine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Output {
|
||||
/// Real audio and video. What the app uses.
|
||||
Real,
|
||||
/// No audio device, no window. What conformance uses, so the suite can run
|
||||
/// on a headless runner without claiming the user's speakers.
|
||||
Null,
|
||||
}
|
||||
|
||||
impl MpvPlayer {
|
||||
pub fn new(output: Output) -> Result<Self, PlayerError> {
|
||||
// mpv refuses to start under a non-C LC_NUMERIC, and anything that has
|
||||
// initialised GTK before us will have set one.
|
||||
unsafe {
|
||||
let c = std::ffi::CString::new("C").unwrap();
|
||||
libc::setlocale(libc::LC_NUMERIC, c.as_ptr());
|
||||
}
|
||||
|
||||
let mpv = Mpv::new().map_err(|e| PlayerError {
|
||||
message: format!("mpv_create failed: {e:?}"),
|
||||
})?;
|
||||
|
||||
let set = |k: &str, v: &str| {
|
||||
if let Err(e) = mpv.set_property(k, v) {
|
||||
warn!("[MpvPlayer] could not set {k}={v}: {e:?}");
|
||||
}
|
||||
};
|
||||
match output {
|
||||
Output::Real => {
|
||||
set("vo", "libmpv");
|
||||
}
|
||||
Output::Null => {
|
||||
set("ao", "null");
|
||||
set("vo", "null");
|
||||
}
|
||||
}
|
||||
set("msg-level", "all=warn");
|
||||
// Survive a blip rather than ending the item on it.
|
||||
set(
|
||||
"stream-lavf-o",
|
||||
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
|
||||
);
|
||||
|
||||
let player = Self {
|
||||
mpv: Arc::new(mpv),
|
||||
shared: Arc::new(Mutex::new(Shared::default())),
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
rate: 1.0,
|
||||
audio_track: None,
|
||||
subtitle_track: None,
|
||||
};
|
||||
player.spawn_events();
|
||||
Ok(player)
|
||||
}
|
||||
|
||||
fn spawn_events(&self) {
|
||||
let mpv = self.mpv.clone();
|
||||
let shared = self.shared.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ev = mpv.create_event_context();
|
||||
let _ = ev.disable_deprecated_events();
|
||||
// Every property matched below must be observed, or libmpv never
|
||||
// delivers it and the handler is unreachable (DR-239).
|
||||
for prop in ["pause", "eof-reached"] {
|
||||
if let Err(e) = ev.observe_property(prop, libmpv::Format::Flag, 0) {
|
||||
warn!("[MpvPlayer] could not observe {prop}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match ev.wait_event(0.25) {
|
||||
Some(Ok(libmpv::events::Event::FileLoaded)) => {
|
||||
let deferred = {
|
||||
let mut s = shared.lock_safe();
|
||||
// Closed while opening: do not start.
|
||||
if s.phase == Phase::Idle {
|
||||
continue;
|
||||
}
|
||||
s.duration = mpv
|
||||
.get_property::<f64>("duration")
|
||||
.ok()
|
||||
.and_then(duration_from_secs);
|
||||
s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
|
||||
s.phase = Phase::Playing;
|
||||
s.deferred_seek.take()
|
||||
};
|
||||
if let Some(to) = deferred {
|
||||
debug!("[MpvPlayer] applying deferred seek to {to:?}");
|
||||
if let Err(e) = mpv.set_property("time-pos", to.as_secs_f64()) {
|
||||
warn!("[MpvPlayer] deferred seek failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(libmpv::events::Event::PropertyChange { name: "pause", .. })) => {
|
||||
if let Ok(paused) = mpv.get_property::<bool>("pause") {
|
||||
let mut s = shared.lock_safe();
|
||||
if s.phase.has_media() {
|
||||
s.phase = if paused {
|
||||
Phase::Paused
|
||||
} else {
|
||||
Phase::Playing
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(libmpv::events::Event::EndFile(reason))) => {
|
||||
let mut s = shared.lock_safe();
|
||||
// 0 = EOF. Anything else is a stop, a quit or an error,
|
||||
// and must not read as "the item finished".
|
||||
s.phase = if reason == 0 {
|
||||
Phase::Ended
|
||||
} else {
|
||||
Phase::Idle
|
||||
};
|
||||
}
|
||||
Some(Ok(libmpv::events::Event::Shutdown)) => break,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Ok(pos) = mpv.get_property::<f64>("time-pos") {
|
||||
let mut s = shared.lock_safe();
|
||||
if s.phase.has_media() && s.deferred_seek.is_none() {
|
||||
s.position = Duration::from_secs_f64(pos.max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaPlayer for MpvPlayer {
|
||||
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
|
||||
{
|
||||
let mut s = self.shared.lock_safe();
|
||||
*s = Shared {
|
||||
phase: Phase::Opening,
|
||||
open_generation: s.open_generation + 1,
|
||||
..Shared::default()
|
||||
};
|
||||
// Report the requested position immediately, so a caller reading
|
||||
// back during the open sees where it asked to be rather than zero.
|
||||
s.position = req.start;
|
||||
}
|
||||
|
||||
// The whole point. `start` is applied by mpv as it opens the file, so
|
||||
// there is no window in which the position can be asked for and lost.
|
||||
let start = if req.start.is_zero() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
format!("{:.3}", req.start.as_secs_f64())
|
||||
};
|
||||
self.mpv
|
||||
.set_property("start", start.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("could not set start position: {e:?}"),
|
||||
})?;
|
||||
self.mpv
|
||||
.set_property("pause", !req.autoplay)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("could not set pause: {e:?}"),
|
||||
})?;
|
||||
|
||||
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
|
||||
self.mpv
|
||||
.command("loadfile", &[&req.selection.url, "replace"])
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("loadfile failed: {e:?}"),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
self.mpv
|
||||
.set_property("pause", false)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("play failed: {e:?}"),
|
||||
})?;
|
||||
let mut s = self.shared.lock_safe();
|
||||
if s.phase.has_media() && s.phase != Phase::Opening {
|
||||
s.phase = Phase::Playing;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
self.mpv
|
||||
.set_property("pause", true)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("pause failed: {e:?}"),
|
||||
})?;
|
||||
let mut s = self.shared.lock_safe();
|
||||
if s.phase.has_media() && s.phase != Phase::Opening {
|
||||
s.phase = Phase::Paused;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close(&mut self) -> Result<(), PlayerError> {
|
||||
// State first: an open still in flight checks this on FileLoaded and
|
||||
// must not proceed to play after the caller has stopped it.
|
||||
{
|
||||
let mut s = self.shared.lock_safe();
|
||||
*s = Shared {
|
||||
open_generation: s.open_generation,
|
||||
..Shared::default()
|
||||
};
|
||||
}
|
||||
// Idempotent: stopping an already-stopped mpv is not an error worth
|
||||
// propagating, and callers legitimately close twice on teardown.
|
||||
if let Err(e) = self.mpv.command("stop", &[]) {
|
||||
debug!("[MpvPlayer] stop on an idle player: {e:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
|
||||
{
|
||||
let mut s = self.shared.lock_safe();
|
||||
match s.phase {
|
||||
// Held, not dropped. The caller cannot see this window.
|
||||
Phase::Opening => {
|
||||
s.deferred_seek = Some(to);
|
||||
s.position = to;
|
||||
return Ok(());
|
||||
}
|
||||
Phase::Idle | Phase::Failed(_) => {
|
||||
return Err(PlayerError {
|
||||
message: "seek with nothing open".to_string(),
|
||||
})
|
||||
}
|
||||
_ => s.position = to,
|
||||
}
|
||||
}
|
||||
self.mpv
|
||||
.set_property("time-pos", to.as_secs_f64())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("seek failed: {e:?}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
self.volume = clamped;
|
||||
self.mpv
|
||||
.set_property("volume", (clamped as f64) * 100.0)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("set_volume failed: {e:?}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
|
||||
self.muted = muted;
|
||||
self.mpv
|
||||
.set_property("mute", muted)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("set_muted failed: {e:?}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
|
||||
self.rate = rate;
|
||||
self.mpv
|
||||
.set_property("speed", rate)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("set_rate failed: {e:?}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
|
||||
self.audio_track = index;
|
||||
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
|
||||
self.mpv
|
||||
.set_property("aid", value.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("select_audio_track failed: {e:?}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
|
||||
self.subtitle_track = index;
|
||||
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
|
||||
self.mpv
|
||||
.set_property("sid", value.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("select_subtitle_track failed: {e:?}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> PlaybackSnapshot {
|
||||
let s = self.shared.lock_safe();
|
||||
PlaybackSnapshot {
|
||||
phase: s.phase.clone(),
|
||||
position: s.position,
|
||||
duration: s.duration,
|
||||
seekable: s.seekable,
|
||||
volume: self.volume,
|
||||
muted: self.muted,
|
||||
rate: self.rate,
|
||||
audio_track: self.audio_track,
|
||||
subtitle_track: self.subtitle_track,
|
||||
}
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities {
|
||||
video: true,
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
// mpv's HLS demuxer cannot make the server transcode from a new
|
||||
// offset, so a transcoded seek must re-open the stream.
|
||||
seeks_transcoded_in_place: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! mpv's render API, driven into an OpenGL framebuffer we own.
|
||||
//!
|
||||
//! This is the half of native video that is not GTK: create a render context
|
||||
//! over the mpv handle the audio backend already drives, render a frame into a
|
||||
//! texture, and hand that texture id back for the toolkit to composite.
|
||||
//!
|
||||
//! Kept apart from `video_surface` deliberately — everything here is portable
|
||||
//! across the platforms this app targets, while the surface that consumes it is
|
||||
//! not. Windows reuses this file unchanged (DR-237).
|
||||
//!
|
||||
//! TRACES: UR-080 | DR-231, DR-232, IR-033
|
||||
|
||||
use std::ffi::{c_void, CStr, CString};
|
||||
use std::os::raw::{c_char, c_int};
|
||||
use std::ptr;
|
||||
|
||||
use log::{error, info, warn};
|
||||
|
||||
/// GL entry points, resolved once.
|
||||
///
|
||||
/// Only the handful needed to own a framebuffer; mpv resolves everything else
|
||||
/// it needs through [`get_proc_address`].
|
||||
struct Gl {
|
||||
gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
|
||||
delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
|
||||
bind_framebuffer: unsafe extern "C" fn(u32, u32),
|
||||
framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
|
||||
gen_textures: unsafe extern "C" fn(c_int, *mut u32),
|
||||
delete_textures: unsafe extern "C" fn(c_int, *const u32),
|
||||
bind_texture: unsafe extern "C" fn(u32, u32),
|
||||
tex_image_2d:
|
||||
unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
|
||||
tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
|
||||
check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
|
||||
}
|
||||
|
||||
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
||||
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
||||
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
||||
const GL_RGBA: u32 = 0x1908;
|
||||
const GL_RGBA8: c_int = 0x8058;
|
||||
const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
||||
const GL_LINEAR: c_int = 0x2601;
|
||||
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
||||
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
||||
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
||||
|
||||
/// Resolve a GL symbol the way libepoxy actually exports it.
|
||||
///
|
||||
/// **This is the trap that cost the spike a debugging cycle.** libepoxy does not
|
||||
/// export `glFoo` as a function. It exports `epoxy_glFoo` as a *data* symbol
|
||||
/// holding a lazily-resolving function pointer. So the address `dlsym` returns
|
||||
/// is the address *of the pointer*, not of any code: returning it makes mpv jump
|
||||
/// into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL
|
||||
/// call. The value must be read *out of* that location.
|
||||
///
|
||||
/// The `epoxy` crate does this correctly and is unusable here — its
|
||||
/// `gl_generator` dependency pulls a yanked `xml-rs`.
|
||||
///
|
||||
/// TRACES: UR-080 | IR-033
|
||||
unsafe fn resolve(name: &str) -> *mut c_void {
|
||||
let epoxy_name = match CString::new(format!("epoxy_{name}")) {
|
||||
Ok(n) => n,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
let slot = libc::dlsym(libc::RTLD_DEFAULT, epoxy_name.as_ptr());
|
||||
if !slot.is_null() {
|
||||
// The symbol holds the function pointer; return what is stored there.
|
||||
return *(slot as *mut *mut c_void);
|
||||
}
|
||||
|
||||
// Fall back to a plain symbol, for a GL stack that is not behind epoxy.
|
||||
match CString::new(name) {
|
||||
Ok(n) => libc::dlsym(libc::RTLD_DEFAULT, n.as_ptr()),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What mpv calls to find GL entry points. Same rule as [`resolve`].
|
||||
unsafe extern "C" fn get_proc_address(_ctx: *mut c_void, name: *const c_char) -> *mut c_void {
|
||||
if name.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
match CStr::from_ptr(name).to_str() {
|
||||
Ok(n) => resolve(n),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! load {
|
||||
($name:literal) => {{
|
||||
let p = resolve($name);
|
||||
if p.is_null() {
|
||||
error!("[MpvRender] GL symbol not found: {}", $name);
|
||||
return None;
|
||||
}
|
||||
std::mem::transmute(p)
|
||||
}};
|
||||
}
|
||||
|
||||
impl Gl {
|
||||
/// Resolve every entry point, or none — a partially-loaded table would fail
|
||||
/// later at a call site with no context.
|
||||
///
|
||||
/// The transmutes are unannotated on purpose: each target type is declared
|
||||
/// once on the struct field above, and repeating it at the call site would
|
||||
/// be two places to get the same signature wrong.
|
||||
#[allow(clippy::missing_transmute_annotations)]
|
||||
unsafe fn load() -> Option<Self> {
|
||||
Some(Gl {
|
||||
gen_framebuffers: load!("glGenFramebuffers"),
|
||||
delete_framebuffers: load!("glDeleteFramebuffers"),
|
||||
bind_framebuffer: load!("glBindFramebuffer"),
|
||||
framebuffer_texture_2d: load!("glFramebufferTexture2D"),
|
||||
gen_textures: load!("glGenTextures"),
|
||||
delete_textures: load!("glDeleteTextures"),
|
||||
bind_texture: load!("glBindTexture"),
|
||||
tex_image_2d: load!("glTexImage2D"),
|
||||
tex_parameteri: load!("glTexParameteri"),
|
||||
check_framebuffer_status: load!("glCheckFramebufferStatus"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour-renderable framebuffer mpv draws into, sized to the widget.
|
||||
struct Target {
|
||||
fbo: u32,
|
||||
texture: u32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
}
|
||||
|
||||
/// mpv's render context plus the framebuffer it draws into.
|
||||
///
|
||||
/// # Lifetime (DR-232)
|
||||
///
|
||||
/// The render context must not outlive the GL context it was created against.
|
||||
/// `Drop` unregisters mpv's update callback *before* freeing the context, so a
|
||||
/// callback cannot land on a freed pointer, and frees the GL objects while the
|
||||
/// caller still has the context current. The caller is responsible for making
|
||||
/// the GL context current around both creation and drop — see `video_surface`.
|
||||
///
|
||||
/// This is DR-184 on Android restated: a surface outliving its player. The spike
|
||||
/// had no defence at all and saw one unexplained SIGSEGV in a decoder thread.
|
||||
pub struct MpvRenderContext {
|
||||
ctx: *mut libmpv_sys::mpv_render_context,
|
||||
gl: Gl,
|
||||
target: Option<Target>,
|
||||
}
|
||||
|
||||
// The render context is driven only from the GTK main thread; the update
|
||||
// callback merely schedules a redraw and touches nothing here.
|
||||
unsafe impl Send for MpvRenderContext {}
|
||||
|
||||
impl MpvRenderContext {
|
||||
/// Create a render context over an existing mpv handle.
|
||||
///
|
||||
/// The GL context must already be current on this thread.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, IR-033
|
||||
pub unsafe fn new(mpv: *mut libmpv_sys::mpv_handle) -> Option<Self> {
|
||||
let gl = Gl::load()?;
|
||||
|
||||
let mut init = libmpv_sys::mpv_opengl_init_params {
|
||||
get_proc_address: Some(get_proc_address),
|
||||
get_proc_address_ctx: ptr::null_mut(),
|
||||
};
|
||||
let mut api_type = CString::new("opengl").ok()?;
|
||||
// Advanced control is deliberately OFF.
|
||||
//
|
||||
// With it on, mpv expects the client to drive rendering to a stricter
|
||||
// contract than a GTK draw handler can promise — it will wait on us, and
|
||||
// if we in turn wait on its update callback, neither side proceeds. That
|
||||
// deadlock presents as a file that loads, renders one frame, and then
|
||||
// sits there with no audio and a spinner.
|
||||
//
|
||||
// Off, mpv is tolerant of being rendered on the toolkit's schedule,
|
||||
// which is what the frame clock gives us.
|
||||
let mut advanced: c_int = 0;
|
||||
|
||||
let mut params = [
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
|
||||
data: api_type.as_ptr() as *mut c_void,
|
||||
},
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
|
||||
data: &mut init as *mut _ as *mut c_void,
|
||||
},
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_ADVANCED_CONTROL,
|
||||
data: &mut advanced as *mut _ as *mut c_void,
|
||||
},
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: 0,
|
||||
data: ptr::null_mut(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut ctx: *mut libmpv_sys::mpv_render_context = ptr::null_mut();
|
||||
let rc = libmpv_sys::mpv_render_context_create(&mut ctx, mpv, params.as_mut_ptr());
|
||||
// Keep the CString alive until after the call.
|
||||
let _ = &mut api_type;
|
||||
|
||||
if rc < 0 || ctx.is_null() {
|
||||
error!("[MpvRender] mpv_render_context_create failed: {rc}");
|
||||
return None;
|
||||
}
|
||||
|
||||
info!("[MpvRender] render context created");
|
||||
Some(MpvRenderContext {
|
||||
ctx,
|
||||
gl,
|
||||
target: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask to be told when a new frame is ready.
|
||||
///
|
||||
/// Paired with [`report_swap`](Self::report_swap): without both, mpv has
|
||||
/// nothing to time against. The symptom is misleading — playback looks fine
|
||||
/// in a window and judders at fullscreen, which reads as a compositing or
|
||||
/// GPU limit and is neither (DR-233).
|
||||
///
|
||||
/// TRACES: UR-080 | DR-233
|
||||
pub unsafe fn set_update_callback(
|
||||
&mut self,
|
||||
callback: libmpv_sys::mpv_render_update_fn,
|
||||
ctx: *mut c_void,
|
||||
) {
|
||||
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, callback, ctx);
|
||||
}
|
||||
|
||||
/// Whether mpv has a new frame waiting.
|
||||
///
|
||||
/// Asked of mpv directly rather than inferred from its update callback, and
|
||||
/// that distinction is the whole of frame pacing here:
|
||||
///
|
||||
/// - Waiting only on the callback deadlocks — mpv will not progress until
|
||||
/// the client renders, so if the client will not render until mpv says
|
||||
/// so, neither moves. That presents as a file that loads, shows one
|
||||
/// frame, and then sits silent.
|
||||
/// - Rendering on *every* frame-clock tick regardless is the opposite
|
||||
/// error: `report_swap` then claims a presentation far more often than
|
||||
/// real frames exist, mpv has nothing coherent to time against, and
|
||||
/// playback judders badly.
|
||||
///
|
||||
/// Polling is neither. It runs on the main thread, costs a single atomic
|
||||
/// read inside mpv, and answers the only question that matters.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-233
|
||||
pub unsafe fn has_frame(&self) -> bool {
|
||||
let flags = libmpv_sys::mpv_render_context_update(self.ctx);
|
||||
(flags & libmpv_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME as u64) != 0
|
||||
}
|
||||
|
||||
/// Render the current frame at `width` x `height`, returning the texture id
|
||||
/// holding it. The GL context must be current.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231
|
||||
pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32> {
|
||||
if width <= 0 || height <= 0 {
|
||||
return None;
|
||||
}
|
||||
self.ensure_target(width, height)?;
|
||||
let target = self.target.as_ref()?;
|
||||
|
||||
let mut fbo = libmpv_sys::mpv_opengl_fbo {
|
||||
fbo: target.fbo as c_int,
|
||||
w: width as c_int,
|
||||
h: height as c_int,
|
||||
internal_format: 0,
|
||||
};
|
||||
// GTK's cairo surface has its origin at the top left; mpv defaults to
|
||||
// OpenGL's bottom-left. Without this the picture is drawn upside down —
|
||||
// which looks like a broken decode rather than a coordinate convention.
|
||||
let mut flip: c_int = 1;
|
||||
|
||||
let mut params = [
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
|
||||
data: &mut fbo as *mut _ as *mut c_void,
|
||||
},
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
|
||||
data: &mut flip as *mut _ as *mut c_void,
|
||||
},
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: 0,
|
||||
data: ptr::null_mut(),
|
||||
},
|
||||
];
|
||||
|
||||
let rc = libmpv_sys::mpv_render_context_render(self.ctx, params.as_mut_ptr());
|
||||
if rc < 0 {
|
||||
warn!("[MpvRender] render failed: {rc}");
|
||||
return None;
|
||||
}
|
||||
Some(target.texture)
|
||||
}
|
||||
|
||||
/// Tell mpv the frame reached the screen. See [`set_update_callback`].
|
||||
///
|
||||
/// TRACES: UR-080 | DR-233
|
||||
pub unsafe fn report_swap(&self) {
|
||||
libmpv_sys::mpv_render_context_report_swap(self.ctx);
|
||||
}
|
||||
|
||||
/// Create or resize the framebuffer. Reused across frames — reallocating per
|
||||
/// frame would churn GPU memory at the display rate.
|
||||
unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()> {
|
||||
if let Some(t) = &self.target {
|
||||
if t.width == width && t.height == height {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
self.drop_target();
|
||||
|
||||
let gl = &self.gl;
|
||||
let mut texture: u32 = 0;
|
||||
(gl.gen_textures)(1, &mut texture);
|
||||
(gl.bind_texture)(GL_TEXTURE_2D, texture);
|
||||
(gl.tex_image_2d)(
|
||||
GL_TEXTURE_2D,
|
||||
0,
|
||||
GL_RGBA8,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
ptr::null(),
|
||||
);
|
||||
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
(gl.bind_texture)(GL_TEXTURE_2D, 0);
|
||||
|
||||
let mut fbo: u32 = 0;
|
||||
(gl.gen_framebuffers)(1, &mut fbo);
|
||||
(gl.bind_framebuffer)(GL_FRAMEBUFFER, fbo);
|
||||
(gl.framebuffer_texture_2d)(
|
||||
GL_FRAMEBUFFER,
|
||||
GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D,
|
||||
texture,
|
||||
0,
|
||||
);
|
||||
let status = (gl.check_framebuffer_status)(GL_FRAMEBUFFER);
|
||||
(gl.bind_framebuffer)(GL_FRAMEBUFFER, 0);
|
||||
|
||||
if status != GL_FRAMEBUFFER_COMPLETE {
|
||||
error!("[MpvRender] framebuffer incomplete: 0x{status:x}");
|
||||
(gl.delete_framebuffers)(1, &fbo);
|
||||
(gl.delete_textures)(1, &texture);
|
||||
return None;
|
||||
}
|
||||
|
||||
self.target = Some(Target {
|
||||
fbo,
|
||||
texture,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
Some(())
|
||||
}
|
||||
|
||||
unsafe fn drop_target(&mut self) {
|
||||
if let Some(t) = self.target.take() {
|
||||
(self.gl.delete_framebuffers)(1, &t.fbo);
|
||||
(self.gl.delete_textures)(1, &t.texture);
|
||||
}
|
||||
}
|
||||
|
||||
/// Free everything, with the GL context current.
|
||||
///
|
||||
/// Explicit rather than left to `Drop` because the ordering matters and the
|
||||
/// caller is the only one that can guarantee the GL context is current. See
|
||||
/// DR-232.
|
||||
pub unsafe fn destroy(mut self) {
|
||||
// Unregister first: a callback arriving after the free would be a use
|
||||
// after free, and it is scheduled from mpv's own threads.
|
||||
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
|
||||
self.drop_target();
|
||||
libmpv_sys::mpv_render_context_free(self.ctx);
|
||||
self.ctx = ptr::null_mut();
|
||||
info!("[MpvRender] render context freed");
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MpvRenderContext {
|
||||
fn drop(&mut self) {
|
||||
if !self.ctx.is_null() {
|
||||
// Reached only if `destroy` was not called — the GL context may not
|
||||
// be current, so the GL objects are deliberately leaked rather than
|
||||
// deleted against whatever context happens to be bound. Freeing the
|
||||
// render context is still safe and is the part that matters.
|
||||
warn!("[MpvRender] dropped without destroy(); GL objects leaked deliberately");
|
||||
unsafe {
|
||||
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
|
||||
libmpv_sys::mpv_render_context_free(self.ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Whether this process renders video natively, answered once.
|
||||
//!
|
||||
//! Three things need this and must agree: the mpv backend (which has to be
|
||||
//! configured for video *at construction*, before anything plays), the video
|
||||
//! surface (which has nothing to draw otherwise), and `get_player_status`
|
||||
//! (which tells the frontend whether to use a webview `<video>` element).
|
||||
//!
|
||||
//! It is a function rather than three `env::var` checks for the reason this
|
||||
//! codebase keeps rediscovering: a capability answered in several places is a
|
||||
//! capability whose answers drift. Four separate bugs this cycle came from
|
||||
//! exactly that shape — a webview's decode limits applied to ExoPlayer, a
|
||||
//! transcode target contradicting a direct-play claim, a codec list hardcoded in
|
||||
//! a URL builder. One source, read by everyone.
|
||||
//!
|
||||
//! TRACES: UR-080 | DR-231, DR-235
|
||||
|
||||
/// The opt-in for native desktop video.
|
||||
///
|
||||
/// Off by default while the render path is unproven — the webview path still
|
||||
/// works and is what ships. This becomes the *default* (and then the only path)
|
||||
/// when DR-235 lands; the variable is how it is exercised until then.
|
||||
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
|
||||
|
||||
/// Whether mpv should decode and draw video in this process.
|
||||
///
|
||||
/// Read fresh rather than cached: it is consulted a handful of times at startup,
|
||||
/// and a `OnceLock` here would only make it harder to test.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, DR-235
|
||||
pub fn enabled() -> bool {
|
||||
// Only where a native renderer exists. On Android ExoPlayer already does
|
||||
// this and `use_html5_element` is false for entirely separate reasons.
|
||||
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
std::env::var(ENV_FLAG).as_deref(),
|
||||
Ok("1") | Ok("true") | Ok("yes")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Absent, empty, or anything unrecognised means off. A half-set variable
|
||||
/// must not half-enable a renderer — the failure mode would be mpv
|
||||
/// configured for video with nothing drawing it, i.e. audio playing over a
|
||||
/// black rectangle.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231 | UT-216
|
||||
#[test]
|
||||
fn test_only_explicit_truthy_values_enable_it() {
|
||||
let restore = std::env::var(ENV_FLAG).ok();
|
||||
|
||||
for value in ["", "0", "no", "false", "maybe", "2"] {
|
||||
std::env::set_var(ENV_FLAG, value);
|
||||
assert!(!enabled(), "{value:?} must not enable native video");
|
||||
}
|
||||
|
||||
for value in ["1", "true", "yes"] {
|
||||
std::env::set_var(ENV_FLAG, value);
|
||||
assert_eq!(
|
||||
enabled(),
|
||||
cfg!(all(target_os = "linux", not(target_os = "android"))),
|
||||
"{value:?} enables it exactly where a native renderer exists"
|
||||
);
|
||||
}
|
||||
|
||||
std::env::remove_var(ENV_FLAG);
|
||||
assert!(!enabled(), "absent means off");
|
||||
|
||||
match restore {
|
||||
Some(v) => std::env::set_var(ENV_FLAG, v),
|
||||
None => std::env::remove_var(ENV_FLAG),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,6 +541,8 @@ mod tests {
|
||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||
(0..count)
|
||||
.map(|i| MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: format!("item_{}", i),
|
||||
title: format!("Track {}", i + 1),
|
||||
name: Some(format!("Track {}", i + 1)),
|
||||
|
||||
@@ -25,12 +25,14 @@ pub enum VideoSeekStrategy {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `is_local` - Whether the file is a local download
|
||||
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
|
||||
/// * `seeks_transcoded_in_place` - Whether the engine rendering this stream
|
||||
/// can seek a server-side transcode without re-opening it. Declared by the
|
||||
/// engine via `Capabilities`, never inferred from the URL or the renderer.
|
||||
/// * `needs_transcoding` - Whether the content needs transcoding
|
||||
/// * `use_html5` - Whether frontend is using HTML5 video element
|
||||
pub fn determine_video_seek_strategy(
|
||||
is_local: bool,
|
||||
is_hls: bool,
|
||||
seeks_transcoded_in_place: bool,
|
||||
needs_transcoding: bool,
|
||||
use_html5: bool,
|
||||
) -> VideoSeekStrategy {
|
||||
@@ -39,23 +41,39 @@ pub fn determine_video_seek_strategy(
|
||||
return VideoSeekStrategy::LocalNativeSeek;
|
||||
}
|
||||
|
||||
// HLS streams and direct play (non-transcoded) support native seeking
|
||||
if is_hls || !needs_transcoding {
|
||||
if use_html5 {
|
||||
// HTML5 backend - frontend handles seeking via videoElement.currentTime
|
||||
// We don't call backend.seek() because video is in HTML5 element, not in MPV
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
} else {
|
||||
// Native backend (MPV) - backend handles seeking
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
}
|
||||
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
|
||||
// seek lands is a property of the request, not of the stream in hand.
|
||||
//
|
||||
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
|
||||
// the server catch up segment by segment. mpv's HLS demuxer cannot make
|
||||
// Jellyfin transcode from a new offset, so for the native backend a
|
||||
// transcoded seek must re-negotiate the stream regardless of container.
|
||||
//
|
||||
// Before native video shipped, `use_html5` was always true for HLS and the
|
||||
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
|
||||
// used to be a safe proxy for "seekable in place". It no longer is: turning
|
||||
// native video on routed every transcoded seek into a backend seek that
|
||||
// silently does nothing, and presents as "resume does not work".
|
||||
if needs_transcoding {
|
||||
// Whether a transcode can be seeked in place is a property of the
|
||||
// engine, and the engine states it. This used to be inferred from
|
||||
// `is_hls`, which held only while hls.js was the sole HLS renderer —
|
||||
// and stopped holding the moment mpv became one (DR-238).
|
||||
return match (seeks_transcoded_in_place, use_html5) {
|
||||
(true, true) => VideoSeekStrategy::Html5NativeSeek,
|
||||
(true, false) => VideoSeekStrategy::BackendNativeSeek,
|
||||
(false, true) => VideoSeekStrategy::Html5ReloadStream,
|
||||
(false, false) => VideoSeekStrategy::BackendReloadStream,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct play and direct stream are seekable where they sit.
|
||||
if use_html5 {
|
||||
// The frontend seeks via videoElement.currentTime; calling backend.seek()
|
||||
// would move a player that is not the one rendering.
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
} else {
|
||||
// Transcoded non-HLS streams need server-side seek (reload from new position)
|
||||
if use_html5 {
|
||||
VideoSeekStrategy::Html5ReloadStream
|
||||
} else {
|
||||
VideoSeekStrategy::BackendReloadStream
|
||||
}
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,26 +238,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Test video seek strategy for HLS streams
|
||||
/// Non-transcoded streams seek in place regardless of the engine's
|
||||
/// transcode ability, which only applies to transcodes.
|
||||
#[test]
|
||||
fn test_seek_strategy_hls_stream() {
|
||||
// HLS with HTML5 - frontend handles seek, don't call backend
|
||||
fn test_seek_strategy_direct_stream() {
|
||||
// HTML5 renders, so the frontend seeks the element
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, false, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
// HLS with native backend - backend handles seek
|
||||
// The native engine renders, so it seeks
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, false, false),
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
);
|
||||
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
|
||||
// A transcode an engine says it can move: seek in place
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
}
|
||||
|
||||
/// A server-side transcode cannot be seeked by the native backend.
|
||||
///
|
||||
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
|
||||
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
|
||||
/// the server transcode from a new offset, so the stream has to be
|
||||
/// re-negotiated. Before native video existed, `use_html5` was always true
|
||||
/// for HLS and this case was unreachable — turning native video on routed
|
||||
/// every transcoded seek into a native seek that silently does nothing,
|
||||
/// which presents as "resume does not work".
|
||||
///
|
||||
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
|
||||
#[test]
|
||||
fn test_transcoded_seek_follows_the_engines_declared_ability() {
|
||||
// An engine that cannot move a server-side transcode re-opens it,
|
||||
// whichever side is rendering.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, false),
|
||||
VideoSeekStrategy::BackendReloadStream
|
||||
);
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, true),
|
||||
VideoSeekStrategy::Html5ReloadStream
|
||||
);
|
||||
// hls.js can, and says so, so it seeks in place.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
// The container the stream arrives in no longer decides anything: the
|
||||
// same declared ability gives the same answer on the native side.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, false),
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
);
|
||||
}
|
||||
|
||||
/// Test video seek strategy for direct play (non-transcoded) streams
|
||||
#[test]
|
||||
fn test_seek_strategy_direct_play() {
|
||||
|
||||
@@ -232,6 +232,8 @@ mod tests {
|
||||
|
||||
fn create_test_audio_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
@@ -263,6 +265,8 @@ mod tests {
|
||||
|
||||
fn create_test_movie_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
|
||||
@@ -316,6 +316,8 @@ mod tests {
|
||||
// Helper function to create test MediaItem instances
|
||||
fn create_test_media_item(id: &str, title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
name: None,
|
||||
|
||||
@@ -258,6 +258,8 @@ mod tests {
|
||||
/// `StartTimeTicks` is the handoff point.
|
||||
fn handoff_item() -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
title: "Episode 2".to_string(),
|
||||
name: None,
|
||||
@@ -303,6 +305,8 @@ mod tests {
|
||||
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
|
||||
// ranges, so ExoPlayer resumes it where the load failed.
|
||||
let track = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
..handoff_item()
|
||||
};
|
||||
@@ -314,6 +318,8 @@ mod tests {
|
||||
// An HLS playlist declares its segments, so a failed segment load is
|
||||
// retried at that segment, not at the start of the episode.
|
||||
let video = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
media_type: MediaType::Video,
|
||||
..handoff_item()
|
||||
};
|
||||
@@ -324,6 +330,8 @@ mod tests {
|
||||
fn test_downloaded_episode_keeps_the_players_retry() {
|
||||
// A local file has no length problem and no network to lose.
|
||||
let local = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
source: MediaSource::Local {
|
||||
file_path: PathBuf::from("/data/ep2.mkv"),
|
||||
jellyfin_item_id: Some("ep2".to_string()),
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! The native video surface: mpv drawn *behind* Tauri's webview, without
|
||||
//! touching the widget tree.
|
||||
//!
|
||||
//! # Why there is no overlay here
|
||||
//!
|
||||
//! The obvious arrangement — wrap the webview in a `GtkOverlay` with a
|
||||
//! `GtkGLArea` beneath — attaches cleanly and then aborts the process on the
|
||||
//! first click. `tauri-runtime-wry` connects a button-press handler to the
|
||||
//! webview that walks a hard-coded path:
|
||||
//!
|
||||
//! ```text
|
||||
//! webview.parent() // "This one should be GtkBox"
|
||||
//! .parent() // ...and this one the GtkWindow
|
||||
//! .downcast::<gtk::Window>().unwrap()
|
||||
//! ```
|
||||
//!
|
||||
//! An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast
|
||||
//! fails, and because the panic is non-unwinding it takes the app with it.
|
||||
//! Nothing in configuration avoids it: on Linux the handler is attached
|
||||
//! *unconditionally* (the Windows path guards it behind `is_decorated()`), and
|
||||
//! the decoration check that would make it inert runs *after* the unwrap.
|
||||
//!
|
||||
//! So the widget tree is left exactly as Tauri built it. GTK draws a container
|
||||
//! before its children, so rendering into the vbox's own `draw` handler puts the
|
||||
//! picture underneath the webview for free — the same z-order, no reparenting,
|
||||
//! one less widget, and nothing a Tauri upgrade can invalidate by assuming its
|
||||
//! own layout.
|
||||
//!
|
||||
//! TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::c_void;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gtk::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use log::{error, info, warn};
|
||||
|
||||
use super::mpv_render::MpvRenderContext;
|
||||
|
||||
/// GL enum for `gdk_cairo_draw_from_gl`'s `source_type`. GDK takes the GL
|
||||
/// constant itself rather than an enum of its own.
|
||||
const GL_TEXTURE: i32 = 0x1702;
|
||||
|
||||
/// Everything the draw handler needs, shared with the GTK callbacks.
|
||||
struct SurfaceState {
|
||||
gl: Option<gdk::GLContext>,
|
||||
render: Option<MpvRenderContext>,
|
||||
mpv: *mut libmpv_sys::mpv_handle,
|
||||
/// Set by mpv's update callback (on an mpv thread), cleared by the frame
|
||||
/// clock (on the main thread). The whole cross-thread contract.
|
||||
frame_ready: Arc<AtomicBool>,
|
||||
/// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown.
|
||||
/// Null when no callback is registered.
|
||||
callback_ctx: *mut Arc<AtomicBool>,
|
||||
// One-shot diagnostic latches; see `draw`.
|
||||
logged_first_draw: bool,
|
||||
logged_first_frame: bool,
|
||||
/// Last size we logged, so a size change re-reports rather than staying silent.
|
||||
logged_size: (i32, i32),
|
||||
logged_no_gl: bool,
|
||||
logged_no_window: bool,
|
||||
logged_no_size: bool,
|
||||
logged_render_fail: bool,
|
||||
}
|
||||
|
||||
impl SurfaceState {
|
||||
/// Tear down in the order DR-232 requires, with the GL context current.
|
||||
///
|
||||
/// The update callback is unregistered before the context is freed (inside
|
||||
/// `destroy`), and the GL objects go while their context is still bound.
|
||||
/// Getting this wrong is DR-184 on Android restated — a surface outliving
|
||||
/// its player — and is the likeliest cause of the one unexplained SIGSEGV
|
||||
/// the spike recorded.
|
||||
fn teardown(&mut self) {
|
||||
if let Some(render) = self.render.take() {
|
||||
if let Some(gl) = &self.gl {
|
||||
gl.make_current();
|
||||
}
|
||||
// Unregisters the callback before freeing the context.
|
||||
unsafe { render.destroy() };
|
||||
}
|
||||
// Only now is it safe to reclaim what the callback was holding: mpv can
|
||||
// no longer reach it. Freeing it first would be the use-after-free this
|
||||
// ordering exists to prevent.
|
||||
if !self.callback_ctx.is_null() {
|
||||
unsafe { drop(Box::from_raw(self.callback_ctx)) };
|
||||
self.callback_ctx = std::ptr::null_mut();
|
||||
}
|
||||
self.gl = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A live video surface. Dropping it tears the render context down.
|
||||
pub struct VideoSurface {
|
||||
state: Rc<RefCell<SurfaceState>>,
|
||||
widget: gtk::Box,
|
||||
handlers: Vec<glib::SignalHandlerId>,
|
||||
}
|
||||
|
||||
impl Drop for VideoSurface {
|
||||
fn drop(&mut self) {
|
||||
for id in self.handlers.drain(..) {
|
||||
self.widget.disconnect(id);
|
||||
}
|
||||
self.state.borrow_mut().teardown();
|
||||
self.widget.queue_draw();
|
||||
info!("[VideoSurface] detached");
|
||||
}
|
||||
}
|
||||
|
||||
/// mpv's update callback. Runs on an mpv thread, so it does the least possible:
|
||||
/// flags the state and asks GTK to redraw on the main loop.
|
||||
///
|
||||
/// **Nothing here may block or re-enter the player.** The project's deadlock
|
||||
/// gotcha applies with full force — this is called from mpv's own threads.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-233
|
||||
unsafe extern "C" fn on_mpv_update(ctx: *mut c_void) {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
// Runs on an *mpv* thread. It therefore does exactly one thing that is safe
|
||||
// to do from there: set an atomic flag.
|
||||
//
|
||||
// It must not touch GTK, and specifically must not schedule work with
|
||||
// `idle_add_local*`, which requires the calling thread to own the default
|
||||
// main context — from here that panics with "default main context already
|
||||
// acquired by another thread". Nor can it hold the `Rc<RefCell<..>>` state:
|
||||
// an `Rc` is not `Send`, and cloning one from two threads races its
|
||||
// refcount.
|
||||
//
|
||||
// The frame clock on the widget picks the flag up on the main thread. See
|
||||
// `install_frame_clock`.
|
||||
let flag = &*(ctx as *const Arc<AtomicBool>);
|
||||
flag.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Start drawing mpv's video underneath the webview.
|
||||
///
|
||||
/// `vbox` is Tauri's `default_vbox()` — the container the webview already lives
|
||||
/// in. It is not modified; only a `draw` handler is added.
|
||||
///
|
||||
/// Must run on the GTK main thread.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, DR-232, DR-233
|
||||
pub fn attach(vbox: >k::Box, mpv: *mut libmpv_sys::mpv_handle) -> bool {
|
||||
if mpv.is_null() {
|
||||
warn!("[VideoSurface] no mpv handle; native video unavailable");
|
||||
return false;
|
||||
}
|
||||
|
||||
let state = Rc::new(RefCell::new(SurfaceState {
|
||||
gl: None,
|
||||
render: None,
|
||||
mpv,
|
||||
frame_ready: Arc::new(AtomicBool::new(false)),
|
||||
callback_ctx: std::ptr::null_mut(),
|
||||
logged_first_draw: false,
|
||||
logged_first_frame: false,
|
||||
logged_size: (0, 0),
|
||||
logged_no_gl: false,
|
||||
logged_no_window: false,
|
||||
logged_no_size: false,
|
||||
logged_render_fail: false,
|
||||
}));
|
||||
|
||||
let mut handlers = Vec::new();
|
||||
|
||||
// The GL context can only be created once the widget has a GdkWindow, which
|
||||
// is what `realize` announces. Creating it earlier leaves nothing to attach
|
||||
// to — the same ordering constraint the render context has.
|
||||
let realize_state = state.clone();
|
||||
handlers.push(vbox.connect_realize(move |widget| {
|
||||
if let Err(e) = init_gl(widget, &realize_state) {
|
||||
error!("[VideoSurface] GL init failed: {e}");
|
||||
}
|
||||
}));
|
||||
|
||||
// A render context outliving its GL context is the defect DR-232 exists to
|
||||
// prevent, so teardown is bound to `unrealize` rather than left to Drop.
|
||||
let unrealize_state = state.clone();
|
||||
handlers.push(vbox.connect_unrealize(move |_| {
|
||||
unrealize_state.borrow_mut().teardown();
|
||||
}));
|
||||
|
||||
// Drive the render loop from the widget's frame clock, on the main thread,
|
||||
// rendering only when mpv actually has a frame.
|
||||
//
|
||||
// Both nearby mistakes were made and are worth naming, because each has a
|
||||
// symptom that points somewhere else:
|
||||
//
|
||||
// - Waiting on mpv's update callback before rendering deadlocks. mpv does
|
||||
// not progress until the client renders. The file loads, one frame
|
||||
// appears, and everything stops — no picture, no audio, a spinner that
|
||||
// never clears. It reads as a broken stream.
|
||||
// - Rendering unconditionally every tick and reporting a swap each time
|
||||
// tells mpv a frame reached the screen far more often than one did. It
|
||||
// plays, and judders badly. It reads as a GPU or compositing limit.
|
||||
//
|
||||
// Polling `has_frame` each tick is neither.
|
||||
//
|
||||
// The frame clock only ticks while the widget is mapped, so this costs
|
||||
// nothing when the window is hidden.
|
||||
//
|
||||
// TRACES: UR-080 | DR-233
|
||||
let tick_state = state.clone();
|
||||
vbox.add_tick_callback(move |widget, _clock| {
|
||||
// Ask mpv, on the main thread, whether there is anything new. The
|
||||
// update callback's flag is only a hint that something *may* have
|
||||
// happened; `has_frame` is the authority, and asking it here is what
|
||||
// keeps this from either deadlocking or over-presenting.
|
||||
let ready = match tick_state.try_borrow() {
|
||||
Ok(s) => {
|
||||
s.frame_ready.swap(false, Ordering::AcqRel);
|
||||
match s.render.as_ref() {
|
||||
Some(render) => unsafe { render.has_frame() },
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
if ready {
|
||||
widget.queue_draw();
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
|
||||
let draw_state = state.clone();
|
||||
handlers.push(vbox.connect_draw(move |widget, cr| {
|
||||
draw(widget, cr, &draw_state);
|
||||
// Propagate: the webview is a child and must still draw over us.
|
||||
glib::Propagation::Proceed
|
||||
}));
|
||||
|
||||
// The window is already up by the time we are called, so run the init the
|
||||
// `realize` signal would have.
|
||||
if vbox.is_realized() {
|
||||
if let Err(e) = init_gl(vbox, &state) {
|
||||
error!("[VideoSurface] GL init failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
info!("[VideoSurface] attached to Tauri's vbox without reparenting");
|
||||
// The surface lives as long as the window. Held in a thread-local rather
|
||||
// than returned, because it owns `Rc` and GTK types and so is neither `Send`
|
||||
// nor `Sync` — it cannot go into Tauri's managed state, and leaking it would
|
||||
// give up the ability to tear it down at all.
|
||||
//
|
||||
// Teardown does not depend on this being dropped: it is driven by the
|
||||
// widget's `unrealize`, which is the signal that actually means "your GL
|
||||
// context is going away" (DR-232).
|
||||
LIVE_SURFACE.with(|cell| {
|
||||
*cell.borrow_mut() = Some(VideoSurface {
|
||||
state,
|
||||
widget: vbox.clone(),
|
||||
handlers,
|
||||
});
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The one live surface, on the GTK main thread.
|
||||
static LIVE_SURFACE: RefCell<Option<VideoSurface>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Drop the live surface, if there is one. Idempotent.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-232
|
||||
#[allow(dead_code)]
|
||||
pub fn detach() {
|
||||
LIVE_SURFACE.with(|cell| {
|
||||
cell.borrow_mut().take();
|
||||
});
|
||||
}
|
||||
|
||||
/// Create the GL context and the mpv render context over it.
|
||||
fn init_gl(widget: >k::Box, state: &Rc<RefCell<SurfaceState>>) -> Result<(), String> {
|
||||
if state.borrow().render.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let window = widget.window().ok_or("widget has no GdkWindow")?;
|
||||
|
||||
let gl = window
|
||||
.create_gl_context()
|
||||
.map_err(|e| format!("create_gl_context: {e}"))?;
|
||||
gl.realize().map_err(|e| format!("realize: {e}"))?;
|
||||
gl.make_current();
|
||||
|
||||
let mpv = state.borrow().mpv;
|
||||
let mut render =
|
||||
unsafe { MpvRenderContext::new(mpv) }.ok_or("mpv render context creation failed")?;
|
||||
|
||||
// The callback needs an owned handle that outlives this function, so a
|
||||
// clone of the flag is boxed and leaked. `Arc<AtomicBool>` rather than the
|
||||
// state itself: it is the only thing that may cross to an mpv thread. The
|
||||
// pointer is kept so teardown can reclaim it — after the callback is
|
||||
// unregistered, never before.
|
||||
let flag = state.borrow().frame_ready.clone();
|
||||
let ctx_box: *mut Arc<AtomicBool> = Box::into_raw(Box::new(flag));
|
||||
unsafe { render.set_update_callback(Some(on_mpv_update), ctx_box as *mut c_void) };
|
||||
|
||||
let mut s = state.borrow_mut();
|
||||
s.gl = Some(gl);
|
||||
s.render = Some(render);
|
||||
s.callback_ctx = ctx_box;
|
||||
info!("[VideoSurface] GL and render context ready");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw the current frame, if there is one.
|
||||
///
|
||||
/// Runs *before* the children, which is what puts the picture behind the
|
||||
/// webview. Deliberately forgiving: no frame, no GL, or a borrowed state all
|
||||
/// mean "draw nothing this pass" rather than an error — the webview then paints
|
||||
/// over an untouched background, which is exactly the pre-native appearance.
|
||||
fn draw(widget: >k::Box, cr: >k::cairo::Context, state: &Rc<RefCell<SurfaceState>>) {
|
||||
// Report each way of doing nothing exactly once. Without this the whole
|
||||
// path is invisible: a draw handler that never runs, one that bails on a
|
||||
// zero allocation, and one that renders perfectly all look identical from
|
||||
// outside — and mpv stalls if frames are never consumed, so "no audio and
|
||||
// it hangs" is a plausible symptom of *any* of them.
|
||||
fn once(flag: &mut bool, msg: &str) {
|
||||
if !*flag {
|
||||
*flag = true;
|
||||
warn!("[VideoSurface] not drawing: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(mut s) = state.try_borrow_mut() else {
|
||||
return;
|
||||
};
|
||||
if !s.logged_first_draw {
|
||||
s.logged_first_draw = true;
|
||||
info!("[VideoSurface] draw handler running");
|
||||
}
|
||||
let Some(gl) = s.gl.clone() else {
|
||||
let f = &mut s.logged_no_gl;
|
||||
once(f, "no GL context");
|
||||
return;
|
||||
};
|
||||
let Some(window) = widget.window() else {
|
||||
let f = &mut s.logged_no_window;
|
||||
once(f, "widget has no GdkWindow");
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = widget.scale_factor();
|
||||
let width = widget.allocated_width() * scale;
|
||||
let height = widget.allocated_height() * scale;
|
||||
if width <= 0 || height <= 0 {
|
||||
let f = &mut s.logged_no_size;
|
||||
once(f, "zero allocation");
|
||||
return;
|
||||
}
|
||||
|
||||
gl.make_current();
|
||||
|
||||
// Render and end the mutable borrow before touching the latches again.
|
||||
let rendered = match s.render.as_mut() {
|
||||
Some(render) => unsafe { render.render(width, height) },
|
||||
None => return,
|
||||
};
|
||||
let Some(texture) = rendered else {
|
||||
let f = &mut s.logged_render_fail;
|
||||
once(f, "mpv render produced no texture");
|
||||
return;
|
||||
};
|
||||
// Log the first frame, and again whenever the target size changes. Latching
|
||||
// this once per session hid the case that matters: a second file, rendered
|
||||
// at a different size, in a window that never moved. "The picture is a small
|
||||
// box in the middle" and "the picture fills the widget" are indistinguishable
|
||||
// from outside without it.
|
||||
if !s.logged_first_frame || s.logged_size != (width, height) {
|
||||
s.logged_first_frame = true;
|
||||
s.logged_size = (width, height);
|
||||
// The allocation *origin* matters as much as its size. A GtkBox is a
|
||||
// no-window widget, so `widget.window()` is the parent's GdkWindow and
|
||||
// the box sits at an offset inside it. `draw_from_gl` composites into
|
||||
// that window; if it does not honour the cairo translation GTK applied
|
||||
// for this widget, the picture lands at the window origin instead of
|
||||
// the widget's — misaligned by exactly this offset, which is the shape
|
||||
// of a letterbox that does not line up.
|
||||
let alloc = widget.allocation();
|
||||
info!(
|
||||
"[VideoSurface] rendering {width}x{height} at widget origin ({}, {}) scale {scale} (texture {texture})",
|
||||
alloc.x(),
|
||||
alloc.y()
|
||||
);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
cr.draw_from_gl(
|
||||
&window,
|
||||
texture as i32,
|
||||
GL_TEXTURE,
|
||||
scale,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
// Tell mpv the frame reached the screen. Without this it has nothing to
|
||||
// pace against — see DR-233.
|
||||
if let Some(render) = s.render.as_ref() {
|
||||
render.report_swap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user