feat(android): shuffle on system playlist tile play -- Home + Playlists list
android / Build + lint + test (push) Failing after 1m30s

This commit is contained in:
2026-06-03 23:29:42 -04:00
parent 6a7958c921
commit b1a66f18bd
3 changed files with 132 additions and 43 deletions
@@ -69,7 +69,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
@@ -95,11 +95,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades
@@ -259,45 +257,9 @@ class HomeViewModel @Inject constructor(
*/
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
val detail = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
playlistsRepository.systemShuffle(playlist.systemVariant)
} else {
playlistsRepository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException,
) {
poolMessages.trySend("Couldn't load playlist - check your connection")
return@launch
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
"Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}",
)
return@launch
playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it)
}
// Shared with PlaylistDetailViewModel.play - filters out
// unplayable rows (missing trackId or empty streamUrl) so the
// queue can't end up with tracks Media3 silently rejects.
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
return@launch
}
// Drift #564: send the BARE systemVariant string, not
// "playlist:<variant>" — the server's rotation matcher
// (internal/playevents/writer.go systemPlaylistSources)
// keys on the bare variant. Web sends the bare form too
// (web/src/lib/components/PlaylistCard.svelte:83), so this
// brings Android into alignment. Wrong prefix here meant
// system-mix plays from Android Home never advanced the
// rotation.
val source = if (playlist.refreshable) playlist.systemVariant else null
player.setQueue(tracks, initialIndex = 0, source = source)
}.join()
}
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.playlists.data
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.ErrorCopy
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
/**
* Fetch a playlist and hand it to the player as a shuffled queue.
*
* Behavior matches the tile play-button contract used on Home and the
* Playlists list: refreshable system playlists go through systemShuffle
* (server-side rotation-aware order; tagging with the variant advances
* rotation), everything else uses refreshDetail. System playlists are
* then client-side shuffled so the tile feels random rather than
* "start at the rotation head". User playlists keep their authored
* order.
*
* Errors and empty mixes surface through [onMessage] for the caller to
* present as a snackbar / toast / etc. Returns when the player has
* accepted the queue (or an error path bailed).
*/
suspend fun playPlaylistShuffled(
playlist: PlaylistRef,
repository: PlaylistsRepository,
player: PlayerController,
onMessage: (String) -> Unit,
) {
val detail = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
repository.systemShuffle(playlist.systemVariant)
} else {
repository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: TimeoutCancellationException,
) {
onMessage("Couldn't load playlist - check your connection")
return
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}")
return
}
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
onMessage("Mix isn't ready yet - try again in a moment")
return
}
// Drift #564: bare systemVariant string (not "playlist:<variant>") --
// server's rotation matcher keys on the bare variant.
val source = if (playlist.refreshable) playlist.systemVariant else null
// System playlist tile play button == "pick a random song + shuffle
// the rest" UX. Server's rotation-aware order still drives rotation
// bookkeeping via `source`; the client shuffle just removes the
// deterministic "start at rotation head" feel.
val ordered = if (playlist.isSystem) tracks.shuffled() else tracks
player.setQueue(ordered, initialIndex = 0, source = source)
}
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -23,11 +27,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
@@ -37,12 +44,19 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
// ─── ViewModel ───────────────────────────────────────────────────────
@@ -50,9 +64,25 @@ import javax.inject.Inject
@HiltViewModel
class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val player: PlayerController,
private val eventsStream: EventsStream,
connectivity: ConnectivityObserver,
) : ViewModel() {
private val poolMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from playlist tile play taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/** True when the device has no usable internet -- gates refreshable system tiles. */
val offline: StateFlow<Boolean> = connectivity.online
.map { !it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
init {
refresh()
// Live updates: a playlist created/updated/deleted from another
@@ -69,6 +99,15 @@ class PlaylistsListViewModel @Inject constructor(
runCatching { repository.refreshList() }
}
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
playPlaylistShuffled(playlist, repository, player) {
poolMessages.trySend(it)
}
}.join()
}
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll()
.map { list ->
@@ -88,6 +127,10 @@ fun PlaylistsListScreen(
navController: NavHostController,
viewModel: PlaylistsListViewModel = hiltViewModel(),
) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -97,8 +140,10 @@ fun PlaylistsListScreen(
currentRouteName = Playlists::class.qualifiedName,
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
@@ -117,7 +162,9 @@ fun PlaylistsListScreen(
)
is UiState.Success -> PlaylistsGrid(
playlists = s.data,
offline = offline,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onPlay = viewModel::playPlaylist,
)
}
}
@@ -127,7 +174,9 @@ fun PlaylistsListScreen(
@Composable
private fun PlaylistsGrid(
playlists: List<PlaylistRef>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlay: suspend (PlaylistRef) -> Unit,
) {
val systemPlaylists = playlists.filter { it.isSystem }
val userPlaylists = playlists.filter { !it.isSystem }
@@ -142,7 +191,15 @@ private fun PlaylistsGrid(
SectionHeader("System playlists")
}
items(items = systemPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
// Refreshable system tiles need server endpoints (systemShuffle);
// disable when offline. Empty mixes show a snackbar after tap.
playEnabled = playlist.trackCount > 0 &&
!(offline && playlist.refreshable),
)
}
}
if (userPlaylists.isNotEmpty()) {
@@ -150,7 +207,12 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists")
}
items(items = userPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
playEnabled = playlist.trackCount > 0,
)
}
}
}