feat(android/player): self-heal a stale system-playlist / radio queue on total failure
android / Build + lint + test (push) Successful in 3m56s
android / Build + lint + test (push) Successful in 3m56s
Web parity with 27766ae0. When a load error exhausts a fully-unplayable
queue, re-pull the source instead of stopping: a bare-variant source is a
refreshable system playlist (re-pull via PlaylistsRepository.systemShuffle),
"radio:<seed>" re-seeds via RadioController. Reads the source from the
current MediaItem extra; bounded to one re-pull per exhaustion (reset when
a track next loads with real audio) so a still-stale refresh can't loop.
Album / artist / user-playlist / offline sources have nothing to refresh
and still stop. Issue #968.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val radio: RadioController,
|
private val radio: RadioController,
|
||||||
|
private val playlists: PlaylistsRepository,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var lastEvaluatedItemIndex: Int = -1
|
private var lastEvaluatedItemIndex: Int = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
||||||
|
* the last successful play. Capped so a still-broken refresh can't loop;
|
||||||
|
* reset when any track loads with real audio.
|
||||||
|
*/
|
||||||
|
private var selfHealAttempts = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||||
@@ -351,7 +361,12 @@ class PlayerController @Inject constructor(
|
|||||||
if (current == null || upnpEngaged) return
|
if (current == null || upnpEngaged) return
|
||||||
val duration = controller.duration
|
val duration = controller.duration
|
||||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||||
if (!isZeroDuration) return
|
if (!isZeroDuration) {
|
||||||
|
// A track loaded with real audio — clear the self-heal budget so
|
||||||
|
// a later stale queue can recover again.
|
||||||
|
selfHealAttempts = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
playbackErrorEventsChannel.trySend(
|
playbackErrorEventsChannel.trySend(
|
||||||
PlaybackErrorEvent(
|
PlaybackErrorEvent(
|
||||||
trackId = current.id,
|
trackId = current.id,
|
||||||
@@ -367,6 +382,58 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #968: the queue is fully unplayable (every track failed to load). If it
|
||||||
|
* came from a refreshable source — a system playlist (bare-variant source)
|
||||||
|
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
|
||||||
|
* open across the daily rebuild); re-pull it and resume instead of stopping.
|
||||||
|
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
|
||||||
|
* sources have nothing to refresh — stop.
|
||||||
|
*/
|
||||||
|
private fun selfHealOrStop(controller: MediaController) {
|
||||||
|
val source = controller.currentMediaItem
|
||||||
|
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
|
||||||
|
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
|
||||||
|
controller.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
source.startsWith("radio:") -> {
|
||||||
|
selfHealAttempts++
|
||||||
|
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
|
||||||
|
}
|
||||||
|
!source.contains(':') -> { // bare variant = refreshable system playlist
|
||||||
|
selfHealAttempts++
|
||||||
|
scope.launch { selfHealSystem(source) }
|
||||||
|
}
|
||||||
|
else -> controller.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selfHealSystem(variant: String) {
|
||||||
|
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
if (refs.isEmpty()) {
|
||||||
|
stopOnControllerThread()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQueue(refs, initialIndex = 0, source = variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selfHealRadio(seedTrackId: String) {
|
||||||
|
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
|
||||||
|
if (refs.isEmpty()) {
|
||||||
|
stopOnControllerThread()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopOnControllerThread() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
runOnControllerThread(controller) { controller.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||||
|
|
||||||
private suspend fun connectAndObserve() {
|
private suspend fun connectAndObserve() {
|
||||||
@@ -419,13 +486,14 @@ class PlayerController @Inject constructor(
|
|||||||
)
|
)
|
||||||
// A failed load leaves the player IDLE; advance past the bad
|
// A failed load leaves the player IDLE; advance past the bad
|
||||||
// track and re-prepare so one unplayable item doesn't strand
|
// track and re-prepare so one unplayable item doesn't strand
|
||||||
// playback (mirrors the zero_duration skip). Forward-only —
|
// playback (mirrors the zero_duration skip). At the end of a
|
||||||
// stop at the end — bounds a fully-unplayable queue.
|
// fully-unplayable queue, try to self-heal a stale refreshable
|
||||||
|
// source before stopping.
|
||||||
if (controller.hasNextMediaItem()) {
|
if (controller.hasNextMediaItem()) {
|
||||||
controller.seekToNextMediaItem()
|
controller.seekToNextMediaItem()
|
||||||
controller.prepare()
|
controller.prepare()
|
||||||
} else {
|
} else {
|
||||||
controller.stop()
|
selfHealOrStop(controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,6 +841,10 @@ class PlayerController @Inject constructor(
|
|||||||
// they don't need this poll.
|
// they don't need this poll.
|
||||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||||
|
|
||||||
|
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
||||||
|
// the next successful play) so a still-broken refresh can't loop.
|
||||||
|
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||||
|
|||||||
Reference in New Issue
Block a user