fix(android): silent-breakage cluster — search/artist/nowplaying

Three bugs from the v2 parity audit:

* Search: tapping a track result built a single-track queue
  (auto-advance died on track end). Now builds a queue from the full
  visible Loaded tracks list starting at the tapped row, mirroring
  Flutter.
* ArtistDetail: Play button played albums in tracklist order. Flutter
  shuffles. .shuffled() on the fetched list.
* NowPlaying: when the session tore down (queue finished, queue
  cleared from elsewhere) the screen stranded the user on an
  EmptyState with no escape. Replaced with a 500ms-debounced
  popBackStack so the brief null during MediaController IPC bind
  doesn't bounce the user, but a genuine session-end pops them back
  to wherever they came from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:43:55 -04:00
parent 622c90a2d5
commit d99d317563
3 changed files with 32 additions and 11 deletions
@@ -73,15 +73,16 @@ class ArtistDetailViewModel @Inject constructor(
}
/**
* Pull every track across the artist's albums and start playing.
* Network round-trip per click; fine because the user explicitly
* tapped Play and a cache lookup wouldn't be complete anyway
* until per-album hydration finishes.
* Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on
* an artist is implicitly "shuffle this artist", not "play album by
* album in tracklist order". Network round-trip per click; fine
* because the user explicitly tapped Play.
*/
fun playArtist() {
viewModelScope.launch {
try {
val tracks = repository.fetchArtistTracks(artistId)
val tracks = repository.fetchArtistTracks(artistId).shuffled()
if (tracks.isNotEmpty()) {
player.setQueue(
tracks = tracks,
@@ -52,7 +52,6 @@ import com.fabledsword.minstrel.player.RepeatMode
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Queue
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@@ -63,6 +62,7 @@ private const val TRANSPORT_ICON_DP = 36
private const val PLAY_PAUSE_ICON_DP = 56
private const val MS_PER_SECOND = 1000L
private const val SECONDS_PER_MINUTE = 60L
private const val POP_GRACE_MS = 500L
/**
* Full-screen player. Shows the cover (placeholder until AlbumRef join
@@ -85,10 +85,17 @@ fun NowPlayingScreen(
}
val track = state.currentTrack
if (track == null) {
EmptyState(
title = "Nothing playing",
body = "Pick a track from the library to start.",
)
// Session torn down (queue finished + auto-stop, or user cleared
// the queue from elsewhere). Pop back to whichever shell screen
// launched NowPlaying rather than stranding the user on an
// EmptyState with no escape. A short delay swallows the
// momentary null during MediaController IPC bind on cold-mount.
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(POP_GRACE_MS)
if (viewModel.uiState.value.currentTrack == null) {
navController.popBackStack()
}
}
return
}
Scaffold(
@@ -76,9 +76,22 @@ class SearchViewModel @Inject constructor(
setQuery("")
}
/**
* Tapping a search result builds a queue from the full visible
* track-results list starting at the tapped entry, matching Flutter.
* Falls back to single-track playback when the loaded state isn't
* a Success (shouldn't happen in normal flow but keeps the call
* safe).
*/
fun playTrack(track: TrackRef) {
if (track.streamUrl.isEmpty()) return
player.setQueue(listOf(track), initialIndex = 0, source = "search")
val loaded = internal.value.results as? SearchResultsState.Loaded
val playable = loaded?.response?.tracks
?.filter { it.streamUrl.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
?: listOf(track)
val startIndex = playable.indexOfFirst { it.id == track.id }.coerceAtLeast(0)
player.setQueue(playable, initialIndex = startIndex, source = "search")
}
private suspend fun runSearch(q: String) {