diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index cf5a3649..eb913061 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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:" — 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() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt new file mode 100644 index 00000000..4f920d5e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt @@ -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:") -- + // 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) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index ee730e76..b5302718 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -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(Channel.BUFFERED) + + /** Transient snackbar messages from playlist tile play taps. */ + val transientMessages: Flow = poolMessages.receiveAsFlow() + + /** True when the device has no usable internet -- gates refreshable system tiles. */ + val offline: StateFlow = 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>> = 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, + 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, + ) } } }