feat(android): shuffle on system playlist tile play -- Home + Playlists list
android / Build + lint + test (push) Failing after 1m30s
android / Build + lint + test (push) Failing after 1m30s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+64
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user