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.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)
|
||||||
|
}
|
||||||
+64
-2
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user