diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index b84b55b0..5e200171 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -14,6 +14,8 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.fabledsword.minstrel.di.ApplicationScope 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 dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -63,6 +65,7 @@ class PlayerController @Inject constructor( @ApplicationContext private val context: Context, @ApplicationScope private val scope: CoroutineScope, private val radio: RadioController, + private val playlists: PlaylistsRepository, private val playerFactory: PlayerFactory, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, private val remoteState: RemotePlayerState, @@ -111,6 +114,13 @@ class PlayerController @Inject constructor( */ 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 — * the player's own getMediaItem(index) returns Media3 types; we keep @@ -351,7 +361,12 @@ class PlayerController @Inject constructor( if (current == null || upnpEngaged) return val duration = controller.duration 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( PlaybackErrorEvent( 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:") — 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 ────────── private suspend fun connectAndObserve() { @@ -419,13 +486,14 @@ class PlayerController @Inject constructor( ) // A failed load leaves the player IDLE; advance past the bad // track and re-prepare so one unplayable item doesn't strand - // playback (mirrors the zero_duration skip). Forward-only — - // stop at the end — bounds a fully-unplayable queue. + // playback (mirrors the zero_duration skip). At the end of a + // fully-unplayable queue, try to self-heal a stale refreshable + // source before stopping. if (controller.hasNextMediaItem()) { controller.seekToNextMediaItem() controller.prepare() } else { - controller.stop() + selfHealOrStop(controller) } } @@ -773,6 +841,10 @@ class PlayerController @Inject constructor( // they don't need this poll. 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 * both the user-facing snackbar ("Couldn't play X — skipping") and the