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")
|
@POST("api/playlists/system/{kind}/refresh")
|
||||||
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
|
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.History
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
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.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
import com.fabledsword.minstrel.models.AlbumRef
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
@@ -86,9 +88,11 @@ 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
|
||||||
private const val RECENTLY_ADDED_CHUNK = 25
|
private const val RECENTLY_ADDED_CHUNK = 25
|
||||||
|
|
||||||
@@ -117,6 +121,7 @@ data class HomeSections(
|
|||||||
class HomeViewModel @Inject constructor(
|
class HomeViewModel @Inject constructor(
|
||||||
private val homeRepository: HomeRepository,
|
private val homeRepository: HomeRepository,
|
||||||
private val playlistsRepository: PlaylistsRepository,
|
private val playlistsRepository: PlaylistsRepository,
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||||
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
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
|
* Pulls both /home/index and the playlists list. Returns the Job
|
||||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
* for the combined refresh so a pull-to-refresh wrapper can await
|
||||||
@@ -279,6 +392,9 @@ fun HomeScreen(
|
|||||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||||
onMostPlayedTap = viewModel::playMostPlayed,
|
onMostPlayedTap = viewModel::playMostPlayed,
|
||||||
onPlayPool = viewModel::playPool,
|
onPlayPool = viewModel::playPool,
|
||||||
|
onPlayAlbum = viewModel::playAlbum,
|
||||||
|
onPlayArtist = viewModel::playArtistShuffled,
|
||||||
|
onPlayPlaylist = viewModel::playPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +456,9 @@ private fun HomeSuccessContent(
|
|||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
|
onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
|
onPlayAlbum: suspend (String) -> Unit,
|
||||||
|
onPlayArtist: suspend (String) -> Unit,
|
||||||
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -352,19 +471,23 @@ private fun HomeSuccessContent(
|
|||||||
// offline, the cache-backed pool cards lead the row.
|
// offline, the cache-backed pool cards lead the row.
|
||||||
PlaylistsRow(
|
PlaylistsRow(
|
||||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||||
|
offline = offline,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onPlayPool = onPlayPool,
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick)
|
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||||
rediscoverSection(
|
rediscoverSection(
|
||||||
albums = sections.rediscoverAlbums,
|
albums = sections.rediscoverAlbums,
|
||||||
artists = sections.rediscoverArtists,
|
artists = sections.rediscoverArtists,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
|
onPlayAlbum = onPlayAlbum,
|
||||||
|
onPlayArtist = onPlayArtist,
|
||||||
)
|
)
|
||||||
mostPlayedSection(sections.mostPlayedTracks, onMostPlayedTap)
|
mostPlayedSection(sections.mostPlayedTracks, onMostPlayedTap)
|
||||||
lastPlayedSection(sections.lastPlayedArtists, onArtistClick)
|
lastPlayedSection(sections.lastPlayedArtists, onArtistClick, onPlayArtist)
|
||||||
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
|
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,6 +495,7 @@ private fun HomeSuccessContent(
|
|||||||
private fun LazyListScope.recentlyAddedSection(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
|
onPlayAlbum: suspend (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (albums.isEmpty()) {
|
if (albums.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
@@ -394,6 +518,7 @@ private fun LazyListScope.recentlyAddedSection(
|
|||||||
title = if (index == 0) "Recently added" else "",
|
title = if (index == 0) "Recently added" else "",
|
||||||
albums = chunk,
|
albums = chunk,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
|
onPlayAlbum = onPlayAlbum,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,10 +528,19 @@ private fun LazyListScope.rediscoverSection(
|
|||||||
artists: List<HomeTile<ArtistRef>>,
|
artists: List<HomeTile<ArtistRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
|
onPlayAlbum: suspend (String) -> Unit,
|
||||||
|
onPlayArtist: suspend (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
if (albums.isNotEmpty() || artists.isNotEmpty()) {
|
if (albums.isNotEmpty() || artists.isNotEmpty()) {
|
||||||
RediscoverBlock(albums, artists, onAlbumClick, onArtistClick)
|
RediscoverBlock(
|
||||||
|
albums = albums,
|
||||||
|
artists = artists,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onPlayAlbum = onPlayAlbum,
|
||||||
|
onPlayArtist = onPlayArtist,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
EmptySection(
|
EmptySection(
|
||||||
title = "Rediscover",
|
title = "Rediscover",
|
||||||
@@ -435,10 +569,11 @@ private fun LazyListScope.mostPlayedSection(
|
|||||||
private fun LazyListScope.lastPlayedSection(
|
private fun LazyListScope.lastPlayedSection(
|
||||||
artists: List<HomeTile<ArtistRef>>,
|
artists: List<HomeTile<ArtistRef>>,
|
||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
|
onPlayArtist: suspend (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
if (artists.isNotEmpty()) {
|
if (artists.isNotEmpty()) {
|
||||||
ArtistsRow("Last played", artists, onArtistClick)
|
ArtistsRow("Last played", artists, onArtistClick, onPlayArtist)
|
||||||
} else {
|
} else {
|
||||||
EmptySection(
|
EmptySection(
|
||||||
title = "Last played",
|
title = "Last played",
|
||||||
@@ -480,14 +615,16 @@ private fun RediscoverBlock(
|
|||||||
artists: List<HomeTile<ArtistRef>>,
|
artists: List<HomeTile<ArtistRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
|
onPlayAlbum: suspend (String) -> Unit,
|
||||||
|
onPlayArtist: suspend (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (albums.isNotEmpty()) {
|
if (albums.isNotEmpty()) {
|
||||||
AlbumsRow("Rediscover", albums, onAlbumClick)
|
AlbumsRow("Rediscover", albums, onAlbumClick, onPlayAlbum)
|
||||||
}
|
}
|
||||||
if (artists.isNotEmpty()) {
|
if (artists.isNotEmpty()) {
|
||||||
val title = if (albums.isEmpty()) "Rediscover" else ""
|
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,
|
title: String,
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
|
onPlayAlbum: suspend (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = title) {
|
HorizontalScrollRow(title = title) {
|
||||||
items(items = albums, key = { it.id }) { tile ->
|
items(items = albums, key = { it.id }) { tile ->
|
||||||
@@ -504,7 +642,11 @@ private fun AlbumsRow(
|
|||||||
if (album == null) {
|
if (album == null) {
|
||||||
SkeletonAlbumTile()
|
SkeletonAlbumTile()
|
||||||
} else {
|
} 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
|
@Composable
|
||||||
private fun PlaylistsRow(
|
private fun PlaylistsRow(
|
||||||
rowItems: List<PlaylistRowItem>,
|
rowItems: List<PlaylistRowItem>,
|
||||||
|
offline: Boolean,
|
||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = "Playlists") {
|
HorizontalScrollRow(title = "Playlists") {
|
||||||
itemsIndexed(items = rowItems) { _, item ->
|
itemsIndexed(items = rowItems) { _, item ->
|
||||||
@@ -527,6 +671,14 @@ private fun PlaylistsRow(
|
|||||||
is PlaylistRowItem.Real -> PlaylistCard(
|
is PlaylistRowItem.Real -> PlaylistCard(
|
||||||
playlist = item.playlist,
|
playlist = item.playlist,
|
||||||
onClick = { onPlaylistClick(item.playlist.id) },
|
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(
|
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||||
label = item.label,
|
label = item.label,
|
||||||
@@ -602,6 +754,7 @@ private fun ArtistsRow(
|
|||||||
title: String,
|
title: String,
|
||||||
artists: List<HomeTile<ArtistRef>>,
|
artists: List<HomeTile<ArtistRef>>,
|
||||||
onArtistClick: (String) -> Unit,
|
onArtistClick: (String) -> Unit,
|
||||||
|
onPlayArtist: suspend (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = title) {
|
HorizontalScrollRow(title = title) {
|
||||||
items(items = artists, key = { it.id }) { tile ->
|
items(items = artists, key = { it.id }) { tile ->
|
||||||
@@ -609,7 +762,11 @@ private fun ArtistsRow(
|
|||||||
if (artist == null) {
|
if (artist == null) {
|
||||||
SkeletonArtistTile()
|
SkeletonArtistTile()
|
||||||
} else {
|
} 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.models.AlbumRef
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.shared.widgets.CoverTile
|
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
|
* 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]
|
* Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache]
|
||||||
* before firing onClick so the detail screen's AppBar renders title /
|
* before firing onClick so the detail screen's AppBar renders title /
|
||||||
* cover / counts immediately without waiting for the fetch.
|
* 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
|
@Composable
|
||||||
fun AlbumCard(
|
fun AlbumCard(
|
||||||
album: AlbumRef,
|
album: AlbumRef,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onPlay: (suspend () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val seedCache = LocalDetailSeedCache.current
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
@@ -59,6 +67,16 @@ fun AlbumCard(
|
|||||||
modifier = Modifier.size(56.dp),
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -24,18 +24,26 @@ import com.composables.icons.lucide.User
|
|||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.shared.widgets.CoverTile
|
import com.fabledsword.minstrel.shared.widgets.CoverTile
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One artist tile. Circular cover; name centered beneath. Tap fires
|
* One artist tile. Circular cover; name centered beneath. Tap fires
|
||||||
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the
|
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the
|
||||||
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail
|
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail
|
||||||
* AppBar renders the name before the fetch lands.
|
* 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
|
@Composable
|
||||||
fun ArtistCard(
|
fun ArtistCard(
|
||||||
artist: ArtistRef,
|
artist: ArtistRef,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onPlay: (suspend () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val seedCache = LocalDetailSeedCache.current
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
@@ -64,6 +72,16 @@ fun ArtistCard(
|
|||||||
modifier = Modifier.size(56.dp),
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+14
@@ -152,6 +152,20 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
return response.playlistId
|
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 {
|
suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome {
|
||||||
val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1
|
val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1
|
||||||
playlistTrackDao.insertOrIgnore(
|
playlistTrackDao.insertOrIgnore(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.shared.widgets.CoverTile
|
import com.fabledsword.minstrel.shared.widgets.CoverTile
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
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
|
* System playlists carry a small "For You" / "Discover" / etc. label
|
||||||
* subtitle under the name (substituting for the artist-name line on
|
* subtitle under the name (substituting for the artist-name line on
|
||||||
* AlbumCard).
|
* 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
|
@Composable
|
||||||
fun PlaylistCard(
|
fun PlaylistCard(
|
||||||
playlist: PlaylistRef,
|
playlist: PlaylistRef,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onPlay: (suspend () -> Unit)? = null,
|
||||||
|
playEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val seedCache = LocalDetailSeedCache.current
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
@@ -72,6 +81,15 @@ fun PlaylistCard(
|
|||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onPlay != null) {
|
||||||
|
PlayCircleButton(
|
||||||
|
onPlay = onPlay,
|
||||||
|
enabled = playEnabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.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