fix(android): ArtistDetail Play button surfaces failures (audit v3 Bug-7)

playArtist() previously swallowed all exceptions with a comment
about a 'future refinement' — if the tracks fetch failed or the
artist had no playable tracks, the button looked broken with no
feedback.

Now: ArtistDetailViewModel exposes a transientMessages Flow backed
by a buffered Channel. playArtist sends "Couldn't start playback:
<reason>" on Throwable and "No tracks to play for this artist."
when the result is empty. ArtistDetailScreen collects the Flow into
a Scaffold-level SnackbarHost so users see the failure inline
instead of tapping a dead button repeatedly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:59:52 -04:00
parent 70ef15336e
commit d8459a2674
2 changed files with 25 additions and 5 deletions
@@ -21,6 +21,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -28,7 +30,9 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -59,6 +63,12 @@ fun ArtistDetailScreen(
viewModel: ArtistDetailViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -71,6 +81,7 @@ fun ArtistDetailScreen(
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
@@ -13,10 +13,13 @@ import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.player.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -45,6 +48,10 @@ class ArtistDetailViewModel @Inject constructor(
MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading())
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
/** Transient one-shot messages for the screen-level snackbar (e.g. Play-button failure). */
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
val artistLiked: StateFlow<Boolean> =
likes.observeIsLiked(LikesRepository.ENTITY_ARTIST, artistId)
.stateIn(
@@ -92,7 +99,9 @@ class ArtistDetailViewModel @Inject constructor(
viewModelScope.launch {
try {
val tracks = repository.fetchArtistTracks(artistId).shuffled()
if (tracks.isNotEmpty()) {
if (tracks.isEmpty()) {
transientMessagesChannel.trySend("No tracks to play for this artist.")
} else {
player.setQueue(
tracks = tracks,
initialIndex = 0,
@@ -100,11 +109,11 @@ class ArtistDetailViewModel @Inject constructor(
)
}
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
// Silent failure — the play button has no inline error
// affordance; user can retry by re-tapping. A future
// refinement surfaces this via a snackbar.
transientMessagesChannel.trySend(
"Couldn't start playback: ${e.message ?: "unknown error"}",
)
}
}
}