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
+12 -2
View File
@@ -3343,9 +3343,19 @@ export type StreamKind = "audio" | "video" | "subtitle" |
*/
export type 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
*/
{ strategy: "native"; position: number } |
{ strategy: "native"; selection: StreamSelection; position: number } |
/**
* HTML5 must reload its element with this selection.
*/
+12 -5
View File
@@ -2415,11 +2415,18 @@
mediaSourceId ?? null,
selectedAudioTrackIndex,
);
// The HTML5 path reloads through the adapter, which already set the new
// selection via the bridge. The native path reloads inside Rust and
// returns nothing, so record what was asked for as the ceiling in force.
if (!negotiated) {
defaultQuality = quality;
// Adopt whatever the backend says it opened. The HTML5 path has already
// set this via the adapter bridge, so this is a no-op there; the native
// path reloads inside Rust and this is the only thing that updates the UI.
//
// Assigning it is what keeps the picker honest: `selectedQuality` reads
// the selection's rendition, and a transcode always has one — so without
// this the menu stayed on the first stream's rung while the stream itself
// changed underneath.
//
// TRACES: UR-074, UR-079 | DR-225, DR-226
if (negotiated) {
currentSelection = negotiated;
}
if (videoElement && !videoElement.paused) {
startTimeUpdates();
+3 -2
View File
@@ -221,8 +221,9 @@ async function setStreamQuality(
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
return response.selection;
}
// A native backend reloaded itself; there is no selection on that branch.
return null;
// The native backend reloaded itself, but still reports what it opened — the
// caller needs it to show the rung actually in force.
return response.selection ?? null;
}
async function next() {