fix(android): stop background audio rewinding to where it started
A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".
ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.
A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.
Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:
before 13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
(C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
base, 3.5 minutes after the outage with nothing logged between
after 14:05:08 "declining the player's retry", playback undisturbed off the
buffer for 69s (a fatal load error is only raised when the renderer
next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
re-opening at 785.6s -> READY, and no rewind in the following 7 min
Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.
TRACES: UR-040, UR-004 | DR-203 | UT-200
This commit is contained in:
@@ -20,6 +20,9 @@ import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
@@ -256,6 +259,45 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* (and leak) a focus request we already own. */
|
||||
private var hasAudioFocus = false
|
||||
|
||||
/**
|
||||
* Whether the stream that is loaded may be retried by the player itself.
|
||||
*
|
||||
* Set from Rust on every load; see [StreamRetryDecision] for why the
|
||||
* background-audio handoff transcode must answer no. (DR-203)
|
||||
*/
|
||||
private val streamRetry = StreamRetryDecision()
|
||||
|
||||
/**
|
||||
* The default retry behaviour, except that a stream the player could only
|
||||
* restart is not retried at all.
|
||||
*
|
||||
* `C.TIME_UNSET` makes `ProgressiveMediaPeriod.onLoadError` return
|
||||
* `DONT_RETRY_FATAL` *before* it reaches `configureRetry`, which is the
|
||||
* method that would otherwise reset the sample queues and re-request the URL
|
||||
* from offset 0. The error then surfaces through [onPlayerError] as
|
||||
* recoverable, and Rust re-opens the stream at the position playback
|
||||
* actually reached (DR-129).
|
||||
*
|
||||
* TRACES: UR-040, UR-004 | DR-203
|
||||
*/
|
||||
private val loadErrorHandlingPolicy: LoadErrorHandlingPolicy =
|
||||
object : DefaultLoadErrorHandlingPolicy() {
|
||||
override fun getRetryDelayMsFor(
|
||||
loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo
|
||||
): Long {
|
||||
if (!streamRetry.playerMayRetry) {
|
||||
android.util.Log.w(
|
||||
"JellyTauPlayer",
|
||||
"Load error on a stream that cannot be resumed in place — " +
|
||||
"declining the player's retry so the backend can re-open it: " +
|
||||
"${loadErrorInfo.exception}"
|
||||
)
|
||||
return C.TIME_UNSET
|
||||
}
|
||||
return super.getRetryDelayMsFor(loadErrorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Configure audio attributes for music playback with audio focus handling
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
@@ -273,6 +315,13 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
// Decline the player's own load-error retry for a stream it could
|
||||
// only restart (DR-203). Every other source keeps the default
|
||||
// behaviour, which resumes the failed load where it stopped.
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(appContext)
|
||||
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
)
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
// Pause when the audio output is removed (wired headphones unplugged or
|
||||
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||
@@ -354,6 +403,33 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
updatePlaybackServiceNotification(isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* A jump in the timeline nobody asked for.
|
||||
*
|
||||
* Logged rather than acted on: with the load-error retry declined for
|
||||
* streams that can only be restarted (DR-203), a backwards
|
||||
* `DISCONTINUITY_REASON_INTERNAL` here means the player rewound one
|
||||
* anyway, and this line is what would show it.
|
||||
*/
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
val message = "▶ Position discontinuity: ${oldPosition.positionMs}ms -> " +
|
||||
"${newPosition.positionMs}ms (reason=$reason)"
|
||||
if (reason == Player.DISCONTINUITY_REASON_INTERNAL) {
|
||||
// The player moved the timeline of its own accord — the
|
||||
// signature of the DR-203 rewind. Loud, because with the
|
||||
// retry declined it should no longer be reachable.
|
||||
android.util.Log.w("JellyTauPlayer", "$message — player-initiated")
|
||||
} else if (newPosition.positionMs < oldPosition.positionMs - 1000) {
|
||||
// Backwards, but asked for: a seek, or the re-prepare a
|
||||
// stream resume does (reason REMOVE). Normal, so quiet.
|
||||
android.util.Log.d("JellyTauPlayer", message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
android.util.Log.e("JellyTauPlayer", "▶▶▶ PLAYER ERROR: ${error.errorCodeName}", error)
|
||||
android.util.Log.e("JellyTauPlayer", " Error code: ${error.errorCode}")
|
||||
@@ -845,11 +921,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
artworkUrl: String?,
|
||||
durationMs: Long,
|
||||
mediaType: String = "audio",
|
||||
subtitlesJson: String = "[]"
|
||||
subtitlesJson: String = "[]",
|
||||
nonResumableStream: Boolean = false
|
||||
) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
endedNotified = false
|
||||
// Who owns recovery for this stream, decided in Rust (DR-203). Set
|
||||
// before prepare(), since the first load error can arrive as soon as
|
||||
// the player starts reading.
|
||||
streamRetry.onLoad(nonResumableStream)
|
||||
|
||||
// Store metadata for notification updates
|
||||
currentTitle = title
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.dtourolle.jellytau.player
|
||||
|
||||
/**
|
||||
* Whether the *player* is allowed to retry a failed load of what is currently
|
||||
* loaded, or whether recovery belongs to the backend instead.
|
||||
*
|
||||
* Pure state, deliberately free of any media3 or Android type so the decision is
|
||||
* unit-testable off-device — the same shape as `ScreenWakeState` (DR-202).
|
||||
*
|
||||
* ExoPlayer resumes a failed load in place only when it knows where "in place"
|
||||
* is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
|
||||
* content length is known *or* the extractor produced a seek map with a
|
||||
* duration, and otherwise assumes the source is live — it resets every sample
|
||||
* queue and re-requests the URL from offset 0.
|
||||
*
|
||||
* The background-audio handoff transcode (UR-040) satisfies neither condition:
|
||||
* `/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http` is chunked, so
|
||||
* there is no `Content-Length`, and a live mp3 encode carries no `Xing` header,
|
||||
* so the duration is unset — visible in logcat as every position tick reading
|
||||
* `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so a
|
||||
* restart from offset 0 drops playback back to where audio-only mode began and
|
||||
* carries on from there, and because that is a successful *retry* rather than a
|
||||
* failure, no error and no `STATE_ENDED` is ever reported: the app cannot see it
|
||||
* happen. That is the bug this exists to prevent (DR-203).
|
||||
*
|
||||
* Rust decides which streams those are and says so on every load; this only
|
||||
* remembers the answer for the load-error policy to read. Refusing the retry
|
||||
* turns the silent rewind into a recoverable error, which the backend answers by
|
||||
* re-opening the stream at the position playback actually reached (DR-129).
|
||||
*
|
||||
* TRACES: UR-040, UR-004 | DR-203 | UT-200
|
||||
*/
|
||||
class StreamRetryDecision {
|
||||
@Volatile
|
||||
private var nonResumableStream = false
|
||||
|
||||
/**
|
||||
* Record what is being loaded.
|
||||
*
|
||||
* @param nonResumable whether re-requesting this stream would restart it
|
||||
* rather than continue it — `player_retry_restarts_stream` in Rust.
|
||||
*/
|
||||
fun onLoad(nonResumable: Boolean) {
|
||||
nonResumableStream = nonResumable
|
||||
}
|
||||
|
||||
/** True while the player may handle a load error by retrying it itself. */
|
||||
val playerMayRetry: Boolean
|
||||
get() = !nonResumableStream
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.dtourolle.jellytau.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Who owns recovery for the stream that is loaded.
|
||||
*
|
||||
* TRACES: UR-040, UR-004 | DR-203 | UT-200
|
||||
*/
|
||||
class StreamRetryDecisionTest {
|
||||
|
||||
/** Nothing loaded yet is an ordinary stream: the player retries as it always has. */
|
||||
@Test
|
||||
fun `starts allowing the player to retry`() {
|
||||
assertTrue(StreamRetryDecision().playerMayRetry)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported bug: the length-less handoff transcode can only be "retried"
|
||||
* from its beginning, which replays the episode from the handoff point
|
||||
* without reporting anything. The player must not be allowed to try.
|
||||
*/
|
||||
@Test
|
||||
fun `a non-resumable stream refuses the player its retry`() {
|
||||
val decision = StreamRetryDecision()
|
||||
decision.onLoad(nonResumable = true)
|
||||
assertFalse(decision.playerMayRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary stream keeps the player retry`() {
|
||||
val decision = StreamRetryDecision()
|
||||
decision.onLoad(nonResumable = false)
|
||||
assertTrue(decision.playerMayRetry)
|
||||
}
|
||||
|
||||
/** The next load decides for itself — the handoff must not outlive its item. */
|
||||
@Test
|
||||
fun `loading an ordinary stream after a handoff restores the retry`() {
|
||||
val decision = StreamRetryDecision()
|
||||
decision.onLoad(nonResumable = true)
|
||||
decision.onLoad(nonResumable = false)
|
||||
assertTrue(decision.playerMayRetry)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user