Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ class DualPlayerEngine @Inject constructor(
private var isFocusLossPause = false
private var lastPlayWhenReadyAtMs: Long = 0L
private var lastPlayingAtMs: Long = 0L
// Used to distinguish a STATE_BUFFERING caused by a user seek from a real HAL offload
// reset (where audio underflows mid-playback). Without this, seeking shortly after
// playback starts re-enters BUFFERING within the HAL-reset window and triggers a full
// player rebuild, which leaves the MediaSession briefly pointing at the released player
// and silently drops any subsequent seeks.
private var lastSeekAtMs: Long = 0L

/**
* Set by MusicService once ReplayGain for the incoming track is known.
Expand Down Expand Up @@ -272,9 +278,14 @@ class DualPlayerEngine @Inject constructor(
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> {
val timeSincePlayingMs = SystemClock.elapsedRealtime() - lastPlayingAtMs
val now = SystemClock.elapsedRealtime()
val timeSincePlayingMs = now - lastPlayingAtMs
val timeSinceSeekMs = now - lastSeekAtMs
val isPostSeekBuffering = lastSeekAtMs > 0L && timeSinceSeekMs < 1_500L
if (audioOffloadEnabled && !transitionRunning &&
lastPlayingAtMs > 0L && timeSincePlayingMs < 500L) {
lastPlayingAtMs > 0L && timeSincePlayingMs < 500L &&
!isPostSeekBuffering
) {
disableAudioOffloadForSession(
reason = "HAL offload reset detected: STATE_BUFFERING after ${timeSincePlayingMs}ms of playback"
)
Expand All @@ -285,6 +296,18 @@ class DualPlayerEngine @Inject constructor(
Player.STATE_READY, Player.STATE_IDLE, Player.STATE_ENDED -> cancelAudioOffloadFallback()
}
}

override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
if (reason == Player.DISCONTINUITY_REASON_SEEK ||
reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT
) {
lastSeekAtMs = SystemClock.elapsedRealtime()
}
}
}

fun addPlayerSwapListener(listener: (Player) -> Unit) {
Expand All @@ -307,6 +330,20 @@ class DualPlayerEngine @Inject constructor(
onTransitionFinishedListeners.add(listener)
}

/**
* Notifies the engine that an external caller (UI seek, etc.) is about to issue a
* seek through the MediaController. Used to mark the upcoming STATE_BUFFERING as
* seek-driven so the HAL-reset heuristic does not trigger a player rebuild that
* would race with the in-flight seek command.
*
* Setting this here (synchronously, before the seek dispatches) is more reliable
* than waiting for onPositionDiscontinuity, which is delivered on the next event
* batch and can race with onPlaybackStateChanged on some Media3 versions.
*/
fun notifyExternalSeekInitiated() {
lastSeekAtMs = SystemClock.elapsedRealtime()
}

fun removeTransitionFinishedListener(listener: () -> Unit) {
onTransitionFinishedListeners.remove(listener)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ class PlaybackStateHolder @Inject constructor(
val targetPosition = position.coerceAtLeast(0L)
val currentMediaId = mediaController?.currentMediaItem?.mediaId
rememberPausedPositionOverride(currentMediaId, targetPosition)
// Mark the seek before dispatching so the engine's HAL-reset heuristic does
// not misinterpret the resulting STATE_BUFFERING as an audio HAL underflow and
// rebuild the players (which would race with the in-flight seek command).
dualPlayerEngine.notifyExternalSeekInitiated()
mediaController?.seekTo(targetPosition)
}
}
Expand Down
Loading