From 965ec412d15914b38c21c25cb0daa8f16746b75f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 12:23:33 -0400 Subject: [PATCH] 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) --- .../minstrel/history/ui/HistoryTab.kt | 35 +++++++- .../minstrel/library/ui/LibraryScreen.kt | 17 +++- .../fabledsword/minstrel/likes/ui/LikedTab.kt | 24 +++++- .../playlists/ui/PlaylistDetailScreen.kt | 28 +++++++ .../minstrel/search/ui/SearchScreen.kt | 84 +++++++++++++++++-- 5 files changed, 175 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt index 61a1942a..adb39b43 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -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, onPlay: (TrackRef) -> Unit) { +private fun HistoryList( + entries: List, + 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, + ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index 72b6ccc2..d3c4c2fa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -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() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt index a5c39662..7e03adbd 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt @@ -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, + ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 518658cb..2f2eed42 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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, + ) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt index b966cf2b..861907f7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -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, 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(