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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user