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.Home
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository 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.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard 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.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L 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 private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// Recently Added is laid out in a multi-row LazyHorizontalGrid that // Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades // 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) { suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch { viewModelScope.launch {
val detail = try { playPlaylistShuffled(playlist, playlistsRepository, player) {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) { poolMessages.trySend(it)
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
} }
// 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() }.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.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -23,11 +27,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists 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.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard 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 com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job 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.StateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
// ─── ViewModel ─────────────────────────────────────────────────────── // ─── ViewModel ───────────────────────────────────────────────────────
@@ -50,9 +64,25 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PlaylistsListViewModel @Inject constructor( class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository, private val repository: PlaylistsRepository,
private val player: PlayerController,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
connectivity: ConnectivityObserver,
) : ViewModel() { ) : 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 { init {
refresh() refresh()
// Live updates: a playlist created/updated/deleted from another // Live updates: a playlist created/updated/deleted from another
@@ -69,6 +99,15 @@ class PlaylistsListViewModel @Inject constructor(
runCatching { repository.refreshList() } 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>>> = val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll() repository.observeAll()
.map { list -> .map { list ->
@@ -88,6 +127,10 @@ fun PlaylistsListScreen(
navController: NavHostController, navController: NavHostController,
viewModel: PlaylistsListViewModel = hiltViewModel(), viewModel: PlaylistsListViewModel = hiltViewModel(),
) { ) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
@@ -97,8 +140,10 @@ fun PlaylistsListScreen(
currentRouteName = Playlists::class.qualifiedName, currentRouteName = Playlists::class.qualifiedName,
) )
}, },
snackbarHost = { SnackbarHost(snackbar) },
) { inner -> ) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
@@ -117,7 +162,9 @@ fun PlaylistsListScreen(
) )
is UiState.Success -> PlaylistsGrid( is UiState.Success -> PlaylistsGrid(
playlists = s.data, playlists = s.data,
offline = offline,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onPlay = viewModel::playPlaylist,
) )
} }
} }
@@ -127,7 +174,9 @@ fun PlaylistsListScreen(
@Composable @Composable
private fun PlaylistsGrid( private fun PlaylistsGrid(
playlists: List<PlaylistRef>, playlists: List<PlaylistRef>,
offline: Boolean,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
onPlay: suspend (PlaylistRef) -> Unit,
) { ) {
val systemPlaylists = playlists.filter { it.isSystem } val systemPlaylists = playlists.filter { it.isSystem }
val userPlaylists = playlists.filter { !it.isSystem } val userPlaylists = playlists.filter { !it.isSystem }
@@ -142,7 +191,15 @@ private fun PlaylistsGrid(
SectionHeader("System playlists") SectionHeader("System playlists")
} }
items(items = systemPlaylists, key = { it.id }) { playlist -> 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()) { if (userPlaylists.isNotEmpty()) {
@@ -150,7 +207,12 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists") SectionHeader("Your playlists")
} }
items(items = userPlaylists, key = { it.id }) { playlist -> 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,
)
} }
} }
} }