feat(player): seek strategy follows what the engine says it can do

DR-246. The strategy used to turn on `is_hls` and `use_html5`, decided in a
command handler on behalf of engines it does not own. That is how "who
renders" came to mean "how do I seek", and why a transcoded seek silently did
nothing the moment native video changed the renderer (DR-238).

Engines now declare `Capabilities::seeks_transcoded_in_place` — true for
hls.js, which seeks within the VOD playlist it was handed and lets the server
catch up; false for mpv, whose HLS demuxer cannot make the server transcode
from a new offset. The command asks whichever engine is rendering. Adding an
engine no longer means editing a shared truth table.

The item's transport is not read at the seek site any more; the compiler
flagged it unused, which is the URL-shape input finally disappearing.

A deviation from the spec, recorded deliberately: it called for the engine to
own the decision outright. It cannot. Re-negotiating a stream needs the
repository, which sits above the engine, so the engine states the ability and
the caller acts on it. That still removes the defect — nobody guesses on
another component's behalf — without pretending an engine can reach upward.

Also fixes a latent race in the conformance suite, found by running it: the
seek case asserted immediately, which passes on an engine that records the
target when it accepts a seek and races on one that waits for the decoder to
move. `Harness::await_seek` polls instead, the way the Android suite already
did. It failed with machine load rather than with the code, which is the kind
of test that teaches people to re-run until green.

  MpvPlayer     9/9
  LegacyPlayer  8/9 - still only the mute/rate gap in the old trait

789 tests, clippy -D warnings clean with and without the feature.
This commit is contained in:
2026-08-22 22:21:42 +02:00
parent 5fcf58fa78
commit 6b3d853442
11 changed files with 161 additions and 49 deletions
+1
View File
@@ -441,6 +441,7 @@ Internal architecture, components, and application logic.
| DR-243 | Every engine passes one conformance suite, and a `FakePlayer` implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. `FakePlayer` models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at all | Player | UR-081 | In Progress | | DR-243 | Every engine passes one conformance suite, and a `FakePlayer` implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. `FakePlayer` models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at all | Player | UR-081 | In Progress |
| DR-244 | `MpvPlayer` implements `MediaPlayer` over libmpv, applying the start position at load time via mpv's own `start` option rather than seeking after an asynchronous `loadfile`, and holding a seek that arrives during `Opening` until the file loads. A standalone `player-conformance` binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the app | Player | UR-081, UR-040 | Done | | DR-244 | `MpvPlayer` implements `MediaPlayer` over libmpv, applying the start position at load time via mpv's own `start` option rather than seeking after an asynchronous `loadfile`, and holding a seek that arrives during `Opening` until the file loads. A standalone `player-conformance` binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the app | Player | UR-081, UR-040 | Done |
| DR-245 | `PlayerController` holds a `MediaPlayer` rather than a `PlayerBackend`, and every engine reaches it through that one contract — `LegacyPlayer` carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single `open` carrying its start position, and the controller maps the engine's `Phase` back onto `PlayerState` using the queue, so nothing outside changes. `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | Done | | DR-245 | `PlayerController` holds a `MediaPlayer` rather than a `PlayerBackend`, and every engine reaches it through that one contract — `LegacyPlayer` carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single `open` carrying its start position, and the controller maps the engine's `Phase` back onto `PlayerState` using the queue, so nothing outside changes. `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | Done |
| DR-246 | The seek strategy turns on an ability the engine declares, not on the container the stream arrives in. `Capabilities::seeks_transcoded_in_place` is stated by each engine — true for hls.js, which seeks within the VOD playlist it was handed; false for mpv, which cannot make the server transcode from a new offset — and the command asks the engine currently rendering instead of inferring from `is_hls` and `use_html5`. The item's transport is no longer read at the seek site at all. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the capability and the caller acts on it rather than the engine owning the whole decision | Player | UR-040, UR-081 | Done |
| DR-247 | ExoPlayer can be told where to start. `JellyTauPlayer.load(url, mediaId)` had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via `setMediaItem(item, startPositionMs)`, and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before `prepare()` completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start position | Player | UR-081, UR-005 | Done | | DR-247 | ExoPlayer can be told where to start. `JellyTauPlayer.load(url, mediaId)` had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via `setMediaItem(item, startPositionMs)`, and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before `prepare()` completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start position | Player | UR-081, UR-005 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done | | DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
+23 -20
View File
@@ -1450,7 +1450,7 @@ pub async fn player_seek_video(
// Get current playing item to analyze stream characteristics // Get current playing item to analyze stream characteristics
// Clone what we need to avoid holding locks across await points // Clone what we need to avoid holding locks across await points
let (needs_transcoding, jellyfin_item_id, is_local, transport) = { let (needs_transcoding, jellyfin_item_id, is_local) = {
let controller = player.0.lock().await; let controller = player.0.lock().await;
let queue_arc = controller.queue(); let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?; let queue = queue_arc.lock().map_err(|e| e.to_string())?;
@@ -1466,31 +1466,34 @@ pub async fn player_seek_video(
.ok_or("Current video has no Jellyfin ID")? .ok_or("Current video has no Jellyfin ID")?
.to_string(); .to_string();
// The URL itself is no longer read here: the seek strategy now comes // Neither the URL nor the item's transport is read here any more. The
// from the item's own `transport`, not from inspecting the string. // strategy turns on whether the *engine* can seek a transcode in place,
// which it declares for itself — so the container the stream happens to
// arrive in stopped being a proxy for anything (DR-246).
let is_local_file = matches!(current_item.source, MediaSource::Local { .. }); let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
let needs_trans = current_item.needs_transcoding; (current_item.needs_transcoding, jellyfin_id, is_local_file)
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
}; // Locks are dropped here }; // Locks are dropped here
// The transport comes from the backend's own decision, not from searching // Whether a transcode can be seeked in place is asked of the engine that is
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items // rendering, not guessed from the URL's shape or from who is rendering.
// queued without one fall back to `needs_transcoding`, which is exact: // TRACES: UR-040, UR-079 | DR-238, DR-246
// every transcode this app requests is HLS (DR-140). let seeks_transcoded_in_place = {
// let controller = player.0.lock().await;
// TRACES: UR-004, UR-079 | DR-225, DR-230 controller.capabilities().seeks_transcoded_in_place
let is_hls = match transport {
Some(crate::repository::Transport::Hls) => true,
Some(crate::repository::Transport::Progressive)
| Some(crate::repository::Transport::LocalFile) => false,
None => needs_transcoding,
}; };
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5); let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}", info!(
is_local, is_hls, needs_transcoding, use_html5, strategy); "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
);
match strategy { match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => { VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
+15 -1
View File
@@ -68,6 +68,19 @@ impl<P: MediaPlayer> Harness for EngineHarness<P> {
fn seek_tolerance(&self) -> Duration { fn seek_tolerance(&self) -> Duration {
Duration::from_secs(10) Duration::from_secs(10)
} }
/// Poll until the decoder reports the new position, rather than assuming a
/// seek is visible the instant it is accepted.
fn await_seek(&mut self, target: Duration) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
let pos = self.player.snapshot().position;
if pos.abs_diff(target) <= self.seek_tolerance() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
}
} }
macro_rules! run { macro_rules! run {
@@ -138,7 +151,8 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
std::sync::Arc::new(tokio::sync::Mutex::new(None)), std::sync::Arc::new(tokio::sync::Mutex::new(None)),
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()), std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
) )
.expect("could not create the legacy backend") .expect("could not create the legacy backend"),
crate::player::media_player::Capabilities::native(),
)); ));
} }
} }
+7 -1
View File
@@ -1414,7 +1414,13 @@ pub fn run() {
// so this port swaps a seam rather than four implementations. // so this port swaps a seam rather than four implementations.
// TRACES: UR-081 | DR-245 // TRACES: UR-081 | DR-245
let player_controller = PlayerController::new( let player_controller = PlayerController::new(
Box::new(crate::player::LegacyPlayer::new(backend)), Box::new(crate::player::LegacyPlayer::new(
backend,
// Both native engines decode the stream themselves; the
// webview path declares its own abilities when it becomes
// an engine (DR-248).
crate::player::media_player::Capabilities::native(),
)),
playback_reporter.clone(), playback_reporter.clone(),
position_throttler.clone(), position_throttler.clone(),
); );
+12
View File
@@ -53,6 +53,17 @@ pub trait Harness {
fn seek_tolerance(&self) -> Duration { fn seek_tolerance(&self) -> Duration {
Duration::from_secs(5) 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) { fn assert_near(actual: Duration, expected: Duration, tolerance: Duration, what: &str) {
@@ -151,6 +162,7 @@ pub fn seeks_after_open<H: Harness>(h: &mut H) {
let target = Duration::from_secs(420); let target = Duration::from_secs(420);
h.player().seek(target).expect("seek failed"); h.player().seek(target).expect("seek failed");
h.await_seek(target);
assert_near( assert_near(
h.player().snapshot().position, h.player().snapshot().position,
+2
View File
@@ -72,6 +72,8 @@ impl FakePlayer {
audio_settings: true, audio_settings: true,
subtitle_switching: true, subtitle_switching: true,
audio_track_switching: true, audio_track_switching: true,
// The fake honours a seek in any phase, so it can claim this.
seeks_transcoded_in_place: true,
}, },
} }
} }
+8 -7
View File
@@ -26,6 +26,11 @@ use super::state::PlayerState;
pub struct LegacyPlayer<B: PlayerBackend> { pub struct LegacyPlayer<B: PlayerBackend> {
inner: B, 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 /// 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 /// can do: it knows an item was handed over, not whether the engine is ready
/// for one. That gap is the whole problem. /// for one. That gap is the whole problem.
@@ -33,9 +38,10 @@ pub struct LegacyPlayer<B: PlayerBackend> {
} }
impl<B: PlayerBackend> LegacyPlayer<B> { impl<B: PlayerBackend> LegacyPlayer<B> {
pub fn new(inner: B) -> Self { pub fn new(inner: B, capabilities: Capabilities) -> Self {
Self { Self {
inner, inner,
capabilities,
has_item: false, has_item: false,
} }
} }
@@ -141,11 +147,6 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
} }
fn capabilities(&self) -> Capabilities { fn capabilities(&self) -> Capabilities {
Capabilities { self.capabilities
video: false,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
}
} }
} }
+44
View File
@@ -121,6 +121,50 @@ pub struct Capabilities {
pub subtitle_switching: bool, pub subtitle_switching: bool,
/// Audio tracks can be selected without re-opening. /// Audio tracks can be selected without re-opening.
pub audio_track_switching: bool, pub audio_track_switching: bool,
/// A *server-side transcode* can be seeked without re-opening the stream.
///
/// True for hls.js, which seeks within the VOD playlist it is handed and
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
/// the server transcode from a new offset.
///
/// Declared by the engine rather than inferred by the caller. The previous
/// design decided this from `is_hls` and `use_html5` in a command handler —
/// on behalf of engines it did not own — which is how "who renders" came to
/// mean "how do I seek" and why a transcoded seek silently did nothing the
/// moment native video changed the renderer (DR-238).
///
/// Re-negotiating a stream needs the repository, which sits above the
/// engine, so the engine states the capability and the caller acts on it.
pub seeks_transcoded_in_place: bool,
}
impl Capabilities {
/// What a native engine of this project's kind can do.
///
/// `seeks_transcoded_in_place` is false: both native engines decode the
/// stream themselves and neither can make the server transcode from a new
/// offset. hls.js is the exception, and says so for itself.
pub fn native() -> Self {
Self {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
seeks_transcoded_in_place: false,
}
}
/// An engine that renders through the webview element, where hls.js seeks
/// within the playlist it was handed.
pub fn webview() -> Self {
Self {
video: true,
audio_settings: false,
subtitle_switching: true,
audio_track_switching: false,
seeks_transcoded_in_place: true,
}
}
} }
/// A request to present an item. /// A request to present an item.
+11 -1
View File
@@ -976,6 +976,13 @@ impl PlayerController {
} }
} }
/// 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 /// Get current position
pub fn position(&self) -> f64 { pub fn position(&self) -> f64 {
self.backend.lock_safe().snapshot().position.as_secs_f64() self.backend.lock_safe().snapshot().position.as_secs_f64()
@@ -2244,7 +2251,10 @@ impl Default for PlayerController {
let playback_reporter = Arc::new(TokioMutex::new(None)); let playback_reporter = Arc::new(TokioMutex::new(None));
let position_throttler = Arc::new(EventThrottler::new()); let position_throttler = Arc::new(EventThrottler::new());
Self::new( Self::new(
Box::new(LegacyPlayer::new(NullBackend::new())), Box::new(LegacyPlayer::new(
NullBackend::new(),
crate::player::media_player::Capabilities::native(),
)),
playback_reporter, playback_reporter,
position_throttler, position_throttler,
) )
+3
View File
@@ -373,6 +373,9 @@ impl MediaPlayer for MpvPlayer {
audio_settings: true, audio_settings: true,
subtitle_switching: true, subtitle_switching: true,
audio_track_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,
} }
} }
} }
+35 -19
View File
@@ -25,12 +25,14 @@ pub enum VideoSeekStrategy {
/// ///
/// # Arguments /// # Arguments
/// * `is_local` - Whether the file is a local download /// * `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 /// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element /// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy( pub fn determine_video_seek_strategy(
is_local: bool, is_local: bool,
is_hls: bool, seeks_transcoded_in_place: bool,
needs_transcoding: bool, needs_transcoding: bool,
use_html5: bool, use_html5: bool,
) -> VideoSeekStrategy { ) -> VideoSeekStrategy {
@@ -53,14 +55,15 @@ pub fn determine_video_seek_strategy(
// native video on routed every transcoded seek into a backend seek that // native video on routed every transcoded seek into a backend seek that
// silently does nothing, and presents as "resume does not work". // silently does nothing, and presents as "resume does not work".
if needs_transcoding { if needs_transcoding {
return if use_html5 { // Whether a transcode can be seeked in place is a property of the
if is_hls { // engine, and the engine states it. This used to be inferred from
VideoSeekStrategy::Html5NativeSeek // `is_hls`, which held only while hls.js was the sole HLS renderer —
} else { // and stopped holding the moment mpv became one (DR-238).
VideoSeekStrategy::Html5ReloadStream return match (seeks_transcoded_in_place, use_html5) {
} (true, true) => VideoSeekStrategy::Html5NativeSeek,
} else { (true, false) => VideoSeekStrategy::BackendNativeSeek,
VideoSeekStrategy::BackendReloadStream (false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
}; };
} }
@@ -235,20 +238,21 @@ 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] #[test]
fn test_seek_strategy_hls_stream() { fn test_seek_strategy_direct_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend // HTML5 renders, so the frontend seeks the element
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, true, false, true), determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek VideoSeekStrategy::Html5NativeSeek
); );
// HLS with native backend - backend handles seek // The native engine renders, so it seeks
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, true, false, false), determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek 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!( assert_eq!(
determine_video_seek_strategy(false, true, true, true), determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek VideoSeekStrategy::Html5NativeSeek
@@ -265,18 +269,30 @@ mod tests {
/// every transcoded seek into a native seek that silently does nothing, /// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work". /// which presents as "resume does not work".
/// ///
/// TRACES: UR-040 | DR-238 | UT-217 /// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test] #[test]
fn test_seek_strategy_transcoded_hls_native_backend() { 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!( assert_eq!(
determine_video_seek_strategy(false, true, true, false), determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream VideoSeekStrategy::BackendReloadStream
); );
// The HTML5 side of the same case is unchanged: hls.js seeks in-playlist. 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!( assert_eq!(
determine_video_seek_strategy(false, true, true, true), determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek 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 video seek strategy for direct play (non-transcoded) streams