feat(android): TrackActions button on Playlist / Library tabs / Search

Spreads TrackActionsButton across the second wave of surfaces.
PlaylistDetail track rows, LikedTab tracks, HistoryTab rows, and
Search results now expose the full 7-item menu. Each screen-level
Scaffold gains a SnackbarHost that collects TrackActionsViewModel
transient messages.

Search tracks section upgraded from text-only TextRow to TrackRow
with cover thumb + secondary line (artist or album) + kebab.

MiniPlayer + NowPlaying kebabs follow in a separate commit so the
shell-level snackbar plumbing can land independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:23:33 -04:00
parent d0ff607994
commit 965ec412d1
5 changed files with 175 additions and 13 deletions
@@ -30,6 +30,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -95,7 +96,11 @@ class HistoryTabViewModel @Inject constructor(
// ─── Composable ──────────────────────────────────────────────────────
@Composable
fun HistoryTab(viewModel: HistoryTabViewModel = hiltViewModel()) {
fun HistoryTab(
onNavigateToAlbum: (String) -> Unit = {},
onNavigateToArtist: (String) -> Unit = {},
viewModel: HistoryTabViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
@@ -111,23 +116,40 @@ fun HistoryTab(viewModel: HistoryTabViewModel = hiltViewModel()) {
is HistoryUiState.Success -> HistoryList(
entries = s.entries,
onPlay = viewModel::playTrack,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
}
@Composable
private fun HistoryList(entries: List<HistoryEntry>, onPlay: (TrackRef) -> Unit) {
private fun HistoryList(
entries: List<HistoryEntry>,
onPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = entries, key = { it.id }) { entry ->
HistoryRow(entry = entry, onClick = { onPlay(entry.track) })
HistoryRow(
entry = entry,
onClick = { onPlay(entry.track) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
HorizontalDivider()
}
}
}
@Composable
private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) {
private fun HistoryRow(
entry: HistoryEntry,
onClick: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -158,6 +180,11 @@ private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TrackActionsButton(
track = entry.track,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
@@ -14,10 +14,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
@@ -41,6 +44,7 @@ import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel
/**
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
@@ -60,9 +64,16 @@ import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
fun LibraryScreen(
navController: NavHostController,
viewModel: LibraryViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = LIBRARY_TABS
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
trackActionsViewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
@@ -91,12 +102,16 @@ fun LibraryScreen(
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (selectedTab) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_HISTORY -> HistoryTab()
TAB_HISTORY -> HistoryTab(
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
TAB_LIKED -> LikedTab(navController = navController)
TAB_HIDDEN -> HiddenTab()
}
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -140,6 +141,8 @@ fun LikedTab(
onTrackClick = { index ->
viewModel.playTracks(s.sections.tracks, index)
},
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
}
}
@@ -151,6 +154,8 @@ private fun LikedContent(
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
onTrackClick: (Int) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -185,7 +190,12 @@ private fun LikedContent(
SectionHeader("Tracks", sections.tracks.size)
}
itemsIndexed(items = sections.tracks, key = { _, t -> "trk-${t.id}" }) { index, track ->
LikedTrackRow(track = track, onClick = { onTrackClick(index) })
LikedTrackRow(
track = track,
onClick = { onTrackClick(index) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
HorizontalDivider()
}
}
@@ -216,7 +226,12 @@ private fun SectionHeader(label: String, count: Int) {
}
@Composable
private fun LikedTrackRow(track: TrackRef, onClick: () -> Unit) {
private fun LikedTrackRow(
track: TrackRef,
onClick: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -242,6 +257,11 @@ private fun LikedTrackRow(track: TrackRef, onClick: () -> Unit) {
overflow = TextOverflow.Ellipsis,
)
}
TrackActionsButton(
track = track,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
@@ -27,9 +27,12 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -55,6 +58,8 @@ import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.likes.data.LikesRepository
@@ -62,6 +67,8 @@ import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -152,8 +159,15 @@ class PlaylistDetailViewModel @Inject constructor(
fun PlaylistDetailScreen(
navController: NavHostController,
viewModel: PlaylistDetailViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
trackActionsViewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -166,6 +180,7 @@ fun PlaylistDetailScreen(
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
@@ -184,6 +199,8 @@ fun PlaylistDetailScreen(
viewModel.play(s.detail.tracks, startTrackId = id)
},
onToggleTrackLike = viewModel::toggleLikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
}
}
@@ -206,6 +223,8 @@ private fun PlaylistDetailBody(
onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit,
onToggleTrackLike: (String) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -219,6 +238,8 @@ private fun PlaylistDetailBody(
liked = row.trackId != null && row.trackId in likedTrackIds,
onClick = { row.trackId?.let(onTrackClick) },
onToggleLike = { row.trackId?.let(onToggleTrackLike) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
HorizontalDivider()
}
@@ -327,6 +348,8 @@ private fun TrackRow(
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
val enabled = row.isAvailable
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
@@ -366,6 +389,11 @@ private fun TrackRow(
)
if (enabled) {
LikeButton(liked = liked, onToggle = onToggleLike)
TrackActionsButton(
track = row.toTrackRef(),
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
}
@@ -20,6 +20,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
@@ -48,16 +50,26 @@ import com.fabledsword.minstrel.models.SearchResponseRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
navController: NavHostController,
viewModel: SearchViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(Unit) {
trackActionsViewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
@@ -84,6 +96,7 @@ fun SearchScreen(
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
ResultsPane(
@@ -91,6 +104,8 @@ fun SearchScreen(
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTrackPlay = viewModel::playTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
}
}
@@ -126,6 +141,8 @@ private fun ResultsPane(
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
onTrackPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
when (state) {
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
@@ -135,7 +152,14 @@ private fun ResultsPane(
if (state.response.isEmpty) {
CenteredHint("No matches for that query.")
} else {
ResultsList(state.response, onArtistClick, onAlbumClick, onTrackPlay)
ResultsList(
response = state.response,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
}
@@ -147,6 +171,8 @@ private fun ResultsList(
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
onTrackPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -154,7 +180,7 @@ private fun ResultsList(
) {
artistsSection(response.artists, onArtistClick)
albumsSection(response.albums, onAlbumClick)
tracksSection(response.tracks, onTrackPlay)
tracksSection(response.tracks, onTrackPlay, onNavigateToAlbum, onNavigateToArtist)
}
}
@@ -193,20 +219,66 @@ private fun LazyListScope.albumsSection(
private fun LazyListScope.tracksSection(
tracks: List<TrackRef>,
onTrackPlay: (TrackRef) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
if (tracks.isEmpty()) return
item { SectionHeader("Tracks") }
items(items = tracks, key = { "trk-${it.id}" }) { track ->
TextRow(
primary = track.title,
secondary = track.artistName.takeIf { it.isNotEmpty() }
?: track.albumTitle.takeIf { it.isNotEmpty() },
TrackResultRow(
track = track,
onClick = { onTrackPlay(track) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
HorizontalDivider()
}
}
@Composable
private fun TrackResultRow(
track: TrackRef,
onClick: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = track.streamUrl.isNotEmpty(), onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TrackCoverThumb(coverUrl = track.coverUrl, contentDescription = track.title)
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val secondary = track.artistName.takeIf { it.isNotEmpty() }
?: track.albumTitle.takeIf { it.isNotEmpty() }
if (secondary != null) {
Text(
text = secondary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
TrackActionsButton(
track = track,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
@Composable
private fun SectionHeader(text: String) {
Text(