feat(android): Home Most-Played plays the section + NowPlaying LikeButton (audit v3 §4.7 + §4.15)

§4.7 — The Most-played tiles on Home navigated to the track's album
(a leftover TODO from before the player API existed). Now tapping a
tile replaces the queue with the whole Most-played section and starts
at the tapped index, via HomeViewModel.playMostPlayed →
PlayerController.setQueue(source = "home:most_played"). Mirrors
Flutter's _resolveSectionTracks where Home sections are playable units.

§4.15 — NowPlaying had no inline like affordance — users had to open
the kebab to like the current track. Added a LikeButton at the head of
BottomActionsRow, wired to TrackActionsViewModel.isLikedFlow /
toggleLike (same pattern as MiniPlayer). NowPlayingBody now collects
the like state and threads it down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:26:15 -04:00
parent 57d01d70ad
commit 04ece65a07
2 changed files with 36 additions and 7 deletions
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -106,12 +107,28 @@ sealed interface HomeUiState {
class HomeViewModel @Inject constructor(
private val homeRepository: HomeRepository,
private val playlistsRepository: PlaylistsRepository,
private val player: com.fabledsword.minstrel.player.PlayerController,
) : ViewModel() {
init {
refresh()
}
/**
* Tap on a Most-played tile: replace the queue with the section's
* tracks and start at the tapped index. Mirrors Flutter's
* `_resolveSectionTracks` — Home's section is itself the playable
* unit, not a list of album-detail links.
*/
fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) {
if (tracks.isEmpty()) return
player.setQueue(
tracks = tracks,
initialIndex = startIndex.coerceIn(0, tracks.lastIndex),
source = "home:most_played",
)
}
/**
* Pulls both /home/index and the playlists list. Returns the Job
* for the combined refresh so a pull-to-refresh wrapper can await
@@ -205,6 +222,7 @@ fun HomeScreen(
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
)
}
}
@@ -262,6 +280,7 @@ private fun HomeSuccessContent(
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -300,7 +319,7 @@ private fun HomeSuccessContent(
}
item {
if (sections.mostPlayedTracks.isNotEmpty()) {
MostPlayedRow(sections.mostPlayedTracks, onAlbumClick)
MostPlayedRow(sections.mostPlayedTracks, onMostPlayedTap)
} else {
EmptySection(
title = "Most played",
@@ -406,18 +425,18 @@ private fun ArtistsRow(
/**
* Most-played section. Renders compact track tiles (cover + title + artist).
* Tapping a tile navigates to that track's album for now; the proper
* "play the section as a queue" wiring lands once the player API
* exposes a play-list-from-IDs entry point in Phase 7.
* Tapping a tile replaces the queue with the entire section starting
* at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour
* where Home sections are themselves playable units.
*/
@Composable
private fun MostPlayedRow(
tracks: List<TrackRef>,
onAlbumClick: (String) -> Unit,
onTap: (List<TrackRef>, Int) -> Unit,
) {
HorizontalScrollRow(title = "Most played") {
items(items = tracks, key = { it.id }) { track ->
CompactTrackTile(track = track, onClick = { onAlbumClick(track.albumId) })
itemsIndexed(items = tracks, key = { _, t -> t.id }) { index, track ->
CompactTrackTile(track = track, onClick = { onTap(tracks, index) })
}
}
}
@@ -68,6 +68,7 @@ import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
import com.fabledsword.minstrel.nav.LocalAnimatedContentScope
import com.fabledsword.minstrel.nav.LocalSharedTransitionScope
import com.fabledsword.minstrel.nav.Queue
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@@ -138,6 +139,7 @@ fun NowPlayingScreen(
track = track,
navController = navController,
viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel,
)
}
}
@@ -208,7 +210,10 @@ private fun NowPlayingBody(
track: com.fabledsword.minstrel.models.TrackRef,
navController: NavHostController,
viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
Column(
modifier = Modifier
.fillMaxSize()
@@ -240,6 +245,8 @@ private fun NowPlayingBody(
track = track,
shuffleEnabled = state.shuffleEnabled,
repeatMode = state.repeatMode,
isLiked = isLiked,
onToggleLike = { trackActionsViewModel.toggleLike(track.id, isLiked) },
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeat = viewModel::cycleRepeat,
)
@@ -252,6 +259,8 @@ private fun BottomActionsRow(
track: com.fabledsword.minstrel.models.TrackRef,
shuffleEnabled: Boolean,
repeatMode: RepeatMode,
isLiked: Boolean,
onToggleLike: () -> Unit,
onToggleShuffle: () -> Unit,
onCycleRepeat: () -> Unit,
) {
@@ -261,6 +270,7 @@ private fun BottomActionsRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LikeButton(liked = isLiked, onToggle = onToggleLike)
IconButton(onClick = onToggleShuffle) {
Icon(
imageVector = Lucide.Shuffle,