feat(android): port play-button overlay to Home tiles
android / Build + lint + test (push) Failing after 1m34s
android / Build signed release APK (push) Has been skipped

Mirrors Flutter's PlayCircleButton (library/widgets/play_circle_button.dart)
on the Home AlbumCard / ArtistCard / PlaylistCard surfaces. 44dp accent
disc bottom-right of the cover, parchment Play icon, self-managed
spinner during the fetch-and-queue setup, drop shadow.

New: shared/widgets/PlayCircleButton.kt. Cards gain an opt-in
onPlay: (suspend () -> Unit)? parameter; when null the overlay is
omitted, preserving the Library / Discover / Search / detail surfaces
unchanged. HomeScreen wires three new HomeViewModel suspend methods:

  playAlbum         GET /api/albums/{id}, play tracks from 0
  playArtistShuffled GET /api/artists/{id}/tracks, FY shuffle, play 0
  playPlaylist      systemShuffle for refreshable system playlists,
                    refreshDetail otherwise, 8s timeout

PlaylistsApi gains systemShuffle for the rotation-aware shuffle path
(GET /api/playlists/system/{kind}/shuffle, mirrors playlists.dart).

PlaylistCard play is disabled offline + refreshable (server endpoint
unreachable) and for empty playlists. Album / artist errors surface
via the existing transientMessages snackbar channel, matching the
ArtistDetailViewModel improvement over Flutter's silent fail.

Scope: Home tiles only this slice. Flutter applies the overlay to
the cards everywhere they render; revisit Library / Discover / Search
/ detail surfaces in a follow-up.
This commit is contained in:
2026-05-31 23:45:48 -04:00
parent 5a502c12c9
commit f17610ecd5
7 changed files with 335 additions and 8 deletions
@@ -48,6 +48,16 @@ interface PlaylistsApi {
*/
@POST("api/playlists/system/{kind}/refresh")
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
/**
* GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns
* the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking
* the stored order. Mirrors `playlists.dart.systemShuffle`.
*/
@GET("api/playlists/system/{kind}/shuffle")
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
}
/**
@@ -47,7 +47,9 @@ import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef
@@ -86,9 +88,11 @@ 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
private const val RECENTLY_ADDED_CHUNK = 25
@@ -117,6 +121,7 @@ data class HomeSections(
class HomeViewModel @Inject constructor(
private val homeRepository: HomeRepository,
private val playlistsRepository: PlaylistsRepository,
private val libraryRepository: LibraryRepository,
private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
@@ -178,6 +183,114 @@ class HomeViewModel @Inject constructor(
)
}
/**
* Tap the album play-button overlay: fetch the album detail and
* play its tracks from the first. Mirrors Flutter's AlbumCard
* `_playAlbum`. Suspending so the button's spinner waits for the
* fetch + queue setup; the inner work is launched in viewModelScope
* so cancellation from leaving the screen mid-fetch doesn't kill
* playback. Errors surface via the snackbar channel (Android
* improvement over Flutter's silent-fail; matches the existing
* ArtistDetail play handler).
*/
suspend fun playAlbum(albumId: String) {
viewModelScope.launch {
try {
val detail = libraryRepository.refreshAlbumDetail(albumId)
if (detail.tracks.isEmpty()) {
poolMessages.trySend("This album has no tracks to play.")
} else {
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
)
}
}.join()
}
/**
* Tap the artist play-button overlay: fetch every track across the
* artist's albums, Fisher-Yates shuffle, play from index 0. Mirrors
* Flutter's ArtistCard `_playArtistShuffle`. Empty + error semantics
* follow [playAlbum].
*/
suspend fun playArtistShuffled(artistId: String) {
viewModelScope.launch {
try {
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
if (tracks.isEmpty()) {
poolMessages.trySend("This artist has no tracks to play.")
} else {
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
)
}
}.join()
}
/**
* Tap the playlist play-button overlay: fetch the playlist's tracks
* (rotation-aware shuffle for refreshable system playlists, stored
* order for everything else), 8s timeout, play from index 0.
* Mirrors Flutter PlaylistCard `_playPlaylist`. Empty-mix and
* timeout get their own copy strings; other errors fall through to
* [ErrorCopy.fromThrowable].
*/
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
}
val tracks = detail.tracks.mapNotNull { row ->
row.trackId?.let { trackId ->
TrackRef(
id = trackId,
title = row.title,
albumId = row.albumId.orEmpty(),
albumTitle = row.albumTitle,
artistId = row.artistId.orEmpty(),
artistName = row.artistName,
durationSec = row.durationSec,
streamUrl = row.streamUrl.orEmpty(),
)
}
}
if (tracks.isEmpty()) {
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
return@launch
}
val source = if (playlist.refreshable) "playlist:${playlist.systemVariant}" else null
player.setQueue(tracks, initialIndex = 0, source = source)
}.join()
}
/**
* Pulls both /home/index and the playlists list. Returns the Job
* for the combined refresh so a pull-to-refresh wrapper can await
@@ -279,6 +392,9 @@ fun HomeScreen(
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
onPlayPool = viewModel::playPool,
onPlayAlbum = viewModel::playAlbum,
onPlayArtist = viewModel::playArtistShuffled,
onPlayPlaylist = viewModel::playPlaylist,
)
}
}
@@ -340,6 +456,9 @@ private fun HomeSuccessContent(
onPlaylistClick: (String) -> Unit,
onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -352,19 +471,23 @@ private fun HomeSuccessContent(
// offline, the cache-backed pool cards lead the row.
PlaylistsRow(
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
}
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick)
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
rediscoverSection(
albums = sections.rediscoverAlbums,
artists = sections.rediscoverArtists,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
mostPlayedSection(sections.mostPlayedTracks, onMostPlayedTap)
lastPlayedSection(sections.lastPlayedArtists, onArtistClick)
lastPlayedSection(sections.lastPlayedArtists, onArtistClick, onPlayArtist)
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
}
}
@@ -372,6 +495,7 @@ private fun HomeSuccessContent(
private fun LazyListScope.recentlyAddedSection(
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
) {
if (albums.isEmpty()) {
item {
@@ -394,6 +518,7 @@ private fun LazyListScope.recentlyAddedSection(
title = if (index == 0) "Recently added" else "",
albums = chunk,
onAlbumClick = onAlbumClick,
onPlayAlbum = onPlayAlbum,
)
}
}
@@ -403,10 +528,19 @@ private fun LazyListScope.rediscoverSection(
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
item {
if (albums.isNotEmpty() || artists.isNotEmpty()) {
RediscoverBlock(albums, artists, onAlbumClick, onArtistClick)
RediscoverBlock(
albums = albums,
artists = artists,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
} else {
EmptySection(
title = "Rediscover",
@@ -435,10 +569,11 @@ private fun LazyListScope.mostPlayedSection(
private fun LazyListScope.lastPlayedSection(
artists: List<HomeTile<ArtistRef>>,
onArtistClick: (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
item {
if (artists.isNotEmpty()) {
ArtistsRow("Last played", artists, onArtistClick)
ArtistsRow("Last played", artists, onArtistClick, onPlayArtist)
} else {
EmptySection(
title = "Last played",
@@ -480,14 +615,16 @@ private fun RediscoverBlock(
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
Column {
if (albums.isNotEmpty()) {
AlbumsRow("Rediscover", albums, onAlbumClick)
AlbumsRow("Rediscover", albums, onAlbumClick, onPlayAlbum)
}
if (artists.isNotEmpty()) {
val title = if (albums.isEmpty()) "Rediscover" else ""
ArtistsRow(title, artists, onArtistClick)
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
}
}
}
@@ -497,6 +634,7 @@ private fun AlbumsRow(
title: String,
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
) {
HorizontalScrollRow(title = title) {
items(items = albums, key = { it.id }) { tile ->
@@ -504,7 +642,11 @@ private fun AlbumsRow(
if (album == null) {
SkeletonAlbumTile()
} else {
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
AlbumCard(
album = album,
onClick = { onAlbumClick(album.id) },
onPlay = { onPlayAlbum(album.id) },
)
}
}
}
@@ -513,8 +655,10 @@ private fun AlbumsRow(
@Composable
private fun PlaylistsRow(
rowItems: List<PlaylistRowItem>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
HorizontalScrollRow(title = "Playlists") {
itemsIndexed(items = rowItems) { _, item ->
@@ -527,6 +671,14 @@ private fun PlaylistsRow(
is PlaylistRowItem.Real -> PlaylistCard(
playlist = item.playlist,
onClick = { onPlaylistClick(item.playlist.id) },
onPlay = { onPlayPlaylist(item.playlist) },
// Match Flutter: refreshable system playlists need
// the live server endpoints, so disable their play
// overlay when offline (user can still tap into the
// detail and shuffle all from cache). User playlists
// play from cache + survive offline.
playEnabled = item.playlist.trackCount > 0 &&
!(offline && item.playlist.refreshable),
)
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
label = item.label,
@@ -602,6 +754,7 @@ private fun ArtistsRow(
title: String,
artists: List<HomeTile<ArtistRef>>,
onArtistClick: (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
HorizontalScrollRow(title = title) {
items(items = artists, key = { it.id }) { tile ->
@@ -609,7 +762,11 @@ private fun ArtistsRow(
if (artist == null) {
SkeletonArtistTile()
} else {
ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) })
ArtistCard(
artist = artist,
onClick = { onArtistClick(artist.id) },
onPlay = { onPlayArtist(artist.id) },
)
}
}
}
@@ -21,6 +21,8 @@ import androidx.compose.material3.Icon
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.CoverTile
import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
import androidx.compose.ui.Alignment
/**
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
@@ -28,12 +30,18 @@ import com.fabledsword.minstrel.shared.widgets.CoverTile
* Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache]
* before firing onClick so the detail screen's AppBar renders title /
* cover / counts immediately without waiting for the fetch.
*
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
* circular play button is overlaid at the bottom-right of the cover
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently;
* Library / Discover / detail surfaces leave it null.
*/
@Composable
fun AlbumCard(
album: AlbumRef,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null,
) {
val seedCache = LocalDetailSeedCache.current
Surface(
@@ -59,6 +67,16 @@ fun AlbumCard(
modifier = Modifier.size(56.dp),
)
},
overlay = {
if (onPlay != null) {
PlayCircleButton(
onPlay = onPlay,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(6.dp),
)
}
},
)
Spacer(Modifier.height(8.dp))
Text(
@@ -24,18 +24,26 @@ import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.CoverTile
import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
/**
* One artist tile. Circular cover; name centered beneath. Tap fires
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail
* AppBar renders the name before the fetch lands.
*
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
* circular play button is overlaid at the bottom-right of the avatar
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently.
* Padding is 4dp instead of 6dp because the circular avatar pulls the
* button visually inward at the corner.
*/
@Composable
fun ArtistCard(
artist: ArtistRef,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null,
) {
val seedCache = LocalDetailSeedCache.current
Surface(
@@ -64,6 +72,16 @@ fun ArtistCard(
modifier = Modifier.size(56.dp),
)
},
overlay = {
if (onPlay != null) {
PlayCircleButton(
onPlay = onPlay,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp),
)
}
},
)
Spacer(Modifier.height(8.dp))
Text(
@@ -152,6 +152,20 @@ class PlaylistsRepository @Inject constructor(
return response.playlistId
}
/**
* Pulls the rotation-aware shuffled tracks for a refreshable system
* playlist without rebuilding. Used by Home's play-button overlay
* to advance For You / Discover / Today's mix rotation on tap. No
* Room write — the server's rotation order is per-call, not stored.
*/
suspend fun systemShuffle(variant: String): PlaylistDetailRef {
val wire = api.systemShuffle(variant)
return PlaylistDetailRef(
playlist = wire.toPlaylistRef(),
tracks = wire.tracks.map { it.toDomain() },
)
}
suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome {
val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1
playlistTrackDao.insertOrIgnore(
@@ -23,6 +23,7 @@ import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.CoverTile
import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
@@ -33,12 +34,20 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* System playlists carry a small "For You" / "Discover" / etc. label
* subtitle under the name (substituting for the artist-name line on
* AlbumCard).
*
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
* circular play button is overlaid at the bottom-right of the cover.
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
* uses it for system playlists in offline mode (their server-side
* shuffle endpoint is unreachable) and for empty playlists.
*/
@Composable
fun PlaylistCard(
playlist: PlaylistRef,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null,
playEnabled: Boolean = true,
) {
val seedCache = LocalDetailSeedCache.current
Surface(
@@ -72,6 +81,15 @@ fun PlaylistCard(
.padding(6.dp),
)
}
if (onPlay != null) {
PlayCircleButton(
onPlay = onPlay,
enabled = playEnabled,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(6.dp),
)
}
},
)
Spacer(Modifier.height(8.dp))
@@ -0,0 +1,92 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import kotlinx.coroutines.launch
/**
* Always-visible circular play button overlaid on Home tile cover art
* (AlbumCard / ArtistCard / PlaylistCard). Mirrors
* `flutter_client/lib/library/widgets/play_circle_button.dart`: 44dp
* accent-colored disc, parchment Play icon, drop shadow, self-managed
* loading spinner.
*
* [onPlay] is a suspend lambda so the caller can await the fetch-and-
* queue setup. While it runs, the icon is swapped for a spinner and
* the button rejects further taps (re-entrancy guard). [enabled] false
* renders the disc semi-transparent and disables the tap.
*/
@Composable
fun PlayCircleButton(
onPlay: suspend () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
size: Dp = PLAY_BUTTON_DEFAULT_SIZE.dp,
) {
val scope = rememberCoroutineScope()
var starting by remember { mutableStateOf(false) }
val iconSize = size * PLAY_BUTTON_ICON_RATIO
val alpha = if (enabled) 1f else PLAY_BUTTON_DISABLED_ALPHA
Surface(
onClick = {
if (starting) return@Surface
starting = true
scope.launch {
try {
onPlay()
} finally {
starting = false
}
}
},
modifier = modifier
.size(size)
.alpha(alpha),
enabled = enabled && !starting,
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
tonalElevation = PLAY_BUTTON_ELEVATION.dp,
shadowElevation = PLAY_BUTTON_ELEVATION.dp,
) {
Box(contentAlignment = Alignment.Center) {
if (starting) {
CircularProgressIndicator(
modifier = Modifier.size(iconSize),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = PLAY_BUTTON_SPINNER_STROKE.dp,
)
} else {
Icon(
imageVector = Lucide.Play,
contentDescription = "Play",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(iconSize),
)
}
}
}
}
private const val PLAY_BUTTON_DEFAULT_SIZE = 44
private const val PLAY_BUTTON_ICON_RATIO = 0.5f
private const val PLAY_BUTTON_DISABLED_ALPHA = 0.5f
private const val PLAY_BUTTON_ELEVATION = 2
private const val PLAY_BUTTON_SPINNER_STROKE = 2