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.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -106,12 +107,28 @@ sealed interface HomeUiState {
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 player: com.fabledsword.minstrel.player.PlayerController,
) : ViewModel() { ) : ViewModel() {
init { init {
refresh() 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 * 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
@@ -205,6 +222,7 @@ fun HomeScreen(
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onMostPlayedTap = viewModel::playMostPlayed,
) )
} }
} }
@@ -262,6 +280,7 @@ private fun HomeSuccessContent(
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -300,7 +319,7 @@ private fun HomeSuccessContent(
} }
item { item {
if (sections.mostPlayedTracks.isNotEmpty()) { if (sections.mostPlayedTracks.isNotEmpty()) {
MostPlayedRow(sections.mostPlayedTracks, onAlbumClick) MostPlayedRow(sections.mostPlayedTracks, onMostPlayedTap)
} else { } else {
EmptySection( EmptySection(
title = "Most played", title = "Most played",
@@ -406,18 +425,18 @@ private fun ArtistsRow(
/** /**
* Most-played section. Renders compact track tiles (cover + title + artist). * Most-played section. Renders compact track tiles (cover + title + artist).
* Tapping a tile navigates to that track's album for now; the proper * Tapping a tile replaces the queue with the entire section starting
* "play the section as a queue" wiring lands once the player API * at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour
* exposes a play-list-from-IDs entry point in Phase 7. * where Home sections are themselves playable units.
*/ */
@Composable @Composable
private fun MostPlayedRow( private fun MostPlayedRow(
tracks: List<TrackRef>, tracks: List<TrackRef>,
onAlbumClick: (String) -> Unit, onTap: (List<TrackRef>, Int) -> Unit,
) { ) {
HorizontalScrollRow(title = "Most played") { HorizontalScrollRow(title = "Most played") {
items(items = tracks, key = { it.id }) { track -> itemsIndexed(items = tracks, key = { _, t -> t.id }) { index, track ->
CompactTrackTile(track = track, onClick = { onAlbumClick(track.albumId) }) 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.LocalAnimatedContentScope
import com.fabledsword.minstrel.nav.LocalSharedTransitionScope import com.fabledsword.minstrel.nav.LocalSharedTransitionScope
import com.fabledsword.minstrel.nav.Queue 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.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@@ -138,6 +139,7 @@ fun NowPlayingScreen(
track = track, track = track,
navController = navController, navController = navController,
viewModel = viewModel, viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel,
) )
} }
} }
@@ -208,7 +210,10 @@ private fun NowPlayingBody(
track: com.fabledsword.minstrel.models.TrackRef, track: com.fabledsword.minstrel.models.TrackRef,
navController: NavHostController, navController: NavHostController,
viewModel: PlayerViewModel, viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
) { ) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -240,6 +245,8 @@ private fun NowPlayingBody(
track = track, track = track,
shuffleEnabled = state.shuffleEnabled, shuffleEnabled = state.shuffleEnabled,
repeatMode = state.repeatMode, repeatMode = state.repeatMode,
isLiked = isLiked,
onToggleLike = { trackActionsViewModel.toggleLike(track.id, isLiked) },
onToggleShuffle = viewModel::toggleShuffle, onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeat = viewModel::cycleRepeat, onCycleRepeat = viewModel::cycleRepeat,
) )
@@ -252,6 +259,8 @@ private fun BottomActionsRow(
track: com.fabledsword.minstrel.models.TrackRef, track: com.fabledsword.minstrel.models.TrackRef,
shuffleEnabled: Boolean, shuffleEnabled: Boolean,
repeatMode: RepeatMode, repeatMode: RepeatMode,
isLiked: Boolean,
onToggleLike: () -> Unit,
onToggleShuffle: () -> Unit, onToggleShuffle: () -> Unit,
onCycleRepeat: () -> Unit, onCycleRepeat: () -> Unit,
) { ) {
@@ -261,6 +270,7 @@ private fun BottomActionsRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
LikeButton(liked = isLiked, onToggle = onToggleLike)
IconButton(onClick = onToggleShuffle) { IconButton(onClick = onToggleShuffle) {
Icon( Icon(
imageVector = Lucide.Shuffle, imageVector = Lucide.Shuffle,