feat(android): now-playing row highlight on track surfaces

Audit v2 priority #9. Track rows on AlbumDetail, PlaylistDetail,
LikedTab, HistoryTab, and SearchScreen now render the currently-
playing track's title in accent (primary) color so the user can
spot "this is what's playing" while scrolling a list.

Pattern: each screen pulls a PlayerViewModel via hiltViewModel(),
reads state.currentTrack?.id, threads it through to its track row
composable as nowPlaying: Boolean. Row picks titleColor based on
the flag. No new infrastructure — just a thread-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:04:59 -04:00
parent 225ff35c4c
commit c29d1e0b3d
5 changed files with 73 additions and 6 deletions
@@ -100,8 +100,11 @@ fun HistoryTab(
onNavigateToAlbum: (String) -> Unit = {},
onNavigateToArtist: (String) -> Unit = {},
viewModel: HistoryTabViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
val playingTrackId = playerState.currentTrack?.id
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
HistoryUiState.Loading -> LoadingCentered()
@@ -115,6 +118,7 @@ fun HistoryTab(
)
is HistoryUiState.Success -> HistoryList(
entries = s.entries,
playingTrackId = playingTrackId,
onPlay = viewModel::playTrack,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
@@ -126,6 +130,7 @@ fun HistoryTab(
@Composable
private fun HistoryList(
entries: List<HistoryEntry>,
playingTrackId: String?,
onPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
@@ -134,6 +139,7 @@ private fun HistoryList(
items(items = entries, key = { it.id }) { entry ->
HistoryRow(
entry = entry,
nowPlaying = entry.track.id == playingTrackId,
onClick = { onPlay(entry.track) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
@@ -146,10 +152,16 @@ private fun HistoryList(
@Composable
private fun HistoryRow(
entry: HistoryEntry,
nowPlaying: Boolean,
onClick: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -163,7 +175,7 @@ private fun HistoryRow(
Text(
text = entry.track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -67,6 +67,7 @@ fun AlbumDetailScreen(
navController: NavHostController,
viewModel: AlbumDetailViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
@@ -102,10 +103,12 @@ fun AlbumDetailScreen(
is AlbumDetailUiState.Success -> {
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
AlbumBody(
detail = s.detail,
albumLiked = albumLiked,
likedTrackIds = likedTrackIds,
playingTrackId = playerState.currentTrack?.id,
onPlayAll = { viewModel.play(startTrackId = null) },
onShuffleAll = viewModel::shuffle,
onTrackClick = { id -> viewModel.play(startTrackId = id) },
@@ -130,6 +133,7 @@ private fun AlbumBody(
detail: AlbumDetailRef,
albumLiked: Boolean,
likedTrackIds: Set<String>,
playingTrackId: String?,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit,
@@ -156,6 +160,7 @@ private fun AlbumBody(
TrackRow(
track = track,
liked = track.id in likedTrackIds,
nowPlaying = track.id == playingTrackId,
onClick = { onTrackClick(track.id) },
onToggleLike = { onToggleTrackLike(track.id) },
onNavigateToAlbum = onNavigateToAlbum,
@@ -274,11 +279,17 @@ private fun trackCountLine(album: AlbumRef): String {
private fun TrackRow(
track: TrackRef,
liked: Boolean,
nowPlaying: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -296,7 +307,7 @@ private fun TrackRow(
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -124,8 +124,11 @@ class LikedTabViewModel @Inject constructor(
fun LikedTab(
navController: NavHostController,
viewModel: LikedTabViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
val playingTrackId = playerState.currentTrack?.id
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
LikedTabUiState.Loading -> LoadingCentered()
@@ -140,6 +143,7 @@ fun LikedTab(
)
is LikedTabUiState.Success -> LikedContent(
sections = s.sections,
playingTrackId = playingTrackId,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTrackClick = { index ->
@@ -155,6 +159,7 @@ fun LikedTab(
@Composable
private fun LikedContent(
sections: LikedSections,
playingTrackId: String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
onTrackClick: (Int) -> Unit,
@@ -196,6 +201,7 @@ private fun LikedContent(
itemsIndexed(items = sections.tracks, key = { _, t -> "trk-${t.id}" }) { index, track ->
LikedTrackRow(
track = track,
nowPlaying = track.id == playingTrackId,
onClick = { onTrackClick(index) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
@@ -232,10 +238,16 @@ private fun SectionHeader(label: String, count: Int) {
@Composable
private fun LikedTrackRow(
track: TrackRef,
nowPlaying: Boolean,
onClick: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -249,7 +261,7 @@ private fun LikedTrackRow(
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -197,6 +197,7 @@ fun PlaylistDetailScreen(
navController: NavHostController,
viewModel: PlaylistDetailViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
@@ -231,9 +232,11 @@ fun PlaylistDetailScreen(
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
val playerState by playerViewModel.uiState.collectAsState()
PlaylistDetailBody(
detail = s.detail,
likedTrackIds = likedTrackIds,
playingTrackId = playerState.currentTrack?.id,
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
onShuffleAll = {
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
@@ -262,6 +265,7 @@ private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
private fun PlaylistDetailBody(
detail: PlaylistDetailRef,
likedTrackIds: Set<String>,
playingTrackId: String?,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit,
@@ -279,6 +283,7 @@ private fun PlaylistDetailBody(
TrackRow(
row = row,
liked = row.trackId != null && row.trackId in likedTrackIds,
nowPlaying = row.trackId != null && row.trackId == playingTrackId,
onClick = { row.trackId?.let(onTrackClick) },
onToggleLike = { row.trackId?.let(onToggleTrackLike) },
onNavigateToAlbum = onNavigateToAlbum,
@@ -389,6 +394,7 @@ private fun PlaylistCover(playlist: PlaylistRef) {
private fun TrackRow(
row: PlaylistTrackRef,
liked: Boolean,
nowPlaying: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
@@ -396,6 +402,11 @@ private fun TrackRow(
) {
val enabled = row.isAvailable
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
val baseTitleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -412,7 +423,7 @@ private fun TrackRow(
Text(
text = row.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = rowAlpha),
color = baseTitleColor.copy(alpha = rowAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -66,8 +66,11 @@ fun SearchScreen(
navController: NavHostController,
viewModel: SearchViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
val playingTrackId = playerState.currentTrack?.id
val focusRequester = remember { FocusRequester() }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
@@ -107,6 +110,7 @@ fun SearchScreen(
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
ResultsPane(
state = state.results,
playingTrackId = playingTrackId,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTrackPlay = viewModel::playTrack,
@@ -144,6 +148,7 @@ private fun SearchField(
@Composable
private fun ResultsPane(
state: SearchResultsState,
playingTrackId: String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
onTrackPlay: (TrackRef) -> Unit,
@@ -160,6 +165,7 @@ private fun ResultsPane(
} else {
ResultsList(
response = state.response,
playingTrackId = playingTrackId,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
@@ -174,6 +180,7 @@ private fun ResultsPane(
@Composable
private fun ResultsList(
response: SearchResponseRef,
playingTrackId: String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
onTrackPlay: (TrackRef) -> Unit,
@@ -186,7 +193,13 @@ private fun ResultsList(
) {
artistsSection(response.artists, onArtistClick)
albumsSection(response.albums, onAlbumClick)
tracksSection(response.tracks, onTrackPlay, onNavigateToAlbum, onNavigateToArtist)
tracksSection(
response.tracks,
playingTrackId,
onTrackPlay,
onNavigateToAlbum,
onNavigateToArtist,
)
}
}
@@ -224,6 +237,7 @@ private fun LazyListScope.albumsSection(
private fun LazyListScope.tracksSection(
tracks: List<TrackRef>,
playingTrackId: String?,
onTrackPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
@@ -233,6 +247,7 @@ private fun LazyListScope.tracksSection(
items(items = tracks, key = { "trk-${it.id}" }) { track ->
TrackResultRow(
track = track,
nowPlaying = track.id == playingTrackId,
onClick = { onTrackPlay(track) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
@@ -244,10 +259,16 @@ private fun LazyListScope.tracksSection(
@Composable
private fun TrackResultRow(
track: TrackRef,
nowPlaying: Boolean,
onClick: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -261,7 +282,7 @@ private fun TrackResultRow(
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)