fix(player): tell the native backend's caller what it actually did

Two defects found by running on an Android tablet, both invisible on the
desktop, and both the same mistake: a rule written for the webview applied to a
backend that is not one.

The quality picker froze on the first stream. `StreamQualityResponse::Native`
carried only a position, so nothing replaced the selection the UI holds after a
native quality change. The picker derives the rung in force from that
selection's rendition, and a transcode always has a rendition — so the fallback
that would have used the requested value was never reached. The stream changed
and the menu did not. The native variant now carries the `StreamSelection` the
backend opened, like the HTML5 variant already did.

This was invisible on the desktop because the webview path replaces the
selection as a side effect of reloading its element. It looked correct there for
a reason that does not generalise.

A quality change restarted playback from zero. The resume position came from
`videoElement.currentTime`, which the frontend cannot supply on a native backend
— there is no `<video>` element, so it correctly sends null and the backend
substituted 0. Reading it from the DOM at all inverts the rule that the player
is the authority on playback state; the fallback now asks the controller where
it is. Captured before the negotiation round-trip, so it resumes a few hundred
milliseconds behind rather than ahead, which is the right direction to err.

Also from the tablet, and NOT fixed here because it changes playback behaviour
and deserves its own change: `audio_forces_transcode` judges against
`WEBVIEW_AUDIO_CODECS` on every platform, and `video_audio_codecs` narrows the
advertised direct-play audio set to that same webview list. On Android the
decoder is ExoPlayer. The tablet reports dts among its platform codecs, has it
stripped from the profile, and then has the webview rule force a transcode for
it. That is the third instance of a decode capability tied to the wrong
renderer, and it is what DR-233 exists to collapse — evidence now, not a design
preference.

It also corrects the record on this branch's headline number. The measured 85%
direct-play rate used a hypothetical Android profile including ac3/eac3; this
tablet's MediaCodecList reports neither, so eac3 content — about a third of the
sampled library — correctly transcodes here. 85% was the ceiling of a profile
the app does not send, on hardware that could not use it. The negotiation and
the contract are sound; the figure was not a measurement of what ships.
This commit is contained in:
2026-08-22 13:45:03 +02:00
parent 84cf31b929
commit 4f6cf22419
4 changed files with 66 additions and 12 deletions
+39 -3
View File
@@ -369,8 +369,21 @@ pub enum AudioTrackSwitchResponse {
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum StreamQualityResponse {
/// The native backend was reloaded here; nothing left for the frontend.
/// The native backend was reloaded here; nothing left for the frontend to
/// *do* — but it still has to be told what was negotiated.
///
/// This carried only a position at first, which left the picker on Android
/// pinned to the rendition of the *first* stream: the UI derives the rung in
/// force from the selection it holds, nothing replaced that selection on the
/// native path, and a transcode always has a rendition — so the fallback
/// that would have used the requested value was never reached. The stream
/// changed and the menu did not.
///
/// TRACES: UR-074, UR-079 | DR-225, DR-226
Native {
/// What the backend actually opened, so the UI reflects it rather than
/// assuming the request was honoured verbatim.
selection: StreamSelection,
/// Position playback resumed at.
position: f64,
},
@@ -1718,7 +1731,26 @@ pub async fn player_set_stream_quality(
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::set_playback_quality_override(quality);
let position = current_position.unwrap_or(0.0);
// Where to resume. `current_position` is the *element's* clock, which only
// the webview path has — on a native backend there is no `<video>` and the
// frontend correctly sends null, so trusting it there resumed every quality
// change from zero.
//
// The player is the authority on position (it is the authority on all
// playback state); asking the DOM for it and falling back to 0 inverted
// that. Fall back to what the controller reports instead.
//
// TRACES: UR-005, UR-074 | DR-225
// The guard is bound inside the arm's block so it is dropped before the
// reload below takes the same lock. This codebase has been bitten by a
// MutexGuard living longer than the expression that produced it.
let position = match current_position {
Some(p) => p,
None => {
let controller = player.0.lock().await;
controller.absolute_position()
}
};
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
@@ -1736,6 +1768,7 @@ pub async fn player_set_stream_quality(
});
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
@@ -1763,7 +1796,10 @@ pub async fn player_set_stream_quality(
}
}
Ok(StreamQualityResponse::Native { position })
Ok(StreamQualityResponse::Native {
selection,
position,
})
}
/// Set the active audio track on a native backend directly.