feat(android): port play-button overlay to Home tiles
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:
@@ -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(
|
||||
|
||||
+14
@@ -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
|
||||
Reference in New Issue
Block a user