From d0ff60799426582c88d1e0ef148fa48f6593f562 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 12:18:51 -0400 Subject: [PATCH] feat(android): TrackActions overflow menu + first surface (AlbumDetail) Composes the prior commits (player APIs, mutation kinds, repository methods, sub-sheets) into the 7-item TrackActions sheet and its kebab trigger. TrackActionsViewModel owns state observations + action callbacks + transient snackbar messages. AlbumDetail track rows get the kebab next to LikeButton; the screen-level Scaffold gains a SnackbarHost that surfaces queue/playlist/error messages. Hidden-state observation holds its own Set snapshot since QuarantineRepository doesn't expose a Flow surface yet; refreshes on init and after every flag/unflag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/library/ui/AlbumDetailScreen.kt | 29 ++++ .../track_actions/TrackActionsButton.kt | 49 ++++++ .../track_actions/TrackActionsSheet.kt | 164 ++++++++++++++++++ .../track_actions/TrackActionsViewModel.kt | 126 ++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsButton.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsSheet.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index 1fdcc399..2edc2080 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -27,10 +27,14 @@ 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.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,8 +52,12 @@ import com.composables.icons.lucide.Shuffle import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton +import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel import com.fabledsword.minstrel.theme.FabledSwordFlatTokens @OptIn(ExperimentalMaterial3Api::class) @@ -57,8 +65,15 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens fun AlbumDetailScreen( navController: NavHostController, viewModel: AlbumDetailViewModel = hiltViewModel(), + trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + trackActionsViewModel.transientMessages.collect { msg -> + snackbarHostState.showSnackbar(msg) + } + } Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -71,6 +86,7 @@ fun AlbumDetailScreen( }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { inner -> Box(modifier = Modifier.fillMaxSize().padding(inner)) { when (val s = state) { @@ -91,6 +107,8 @@ fun AlbumDetailScreen( onTrackClick = { id -> viewModel.play(startTrackId = id) }, onToggleAlbumLike = viewModel::toggleLikeAlbum, onToggleTrackLike = viewModel::toggleLikeTrack, + onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, + onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, ) } } @@ -113,6 +131,8 @@ private fun AlbumBody( onTrackClick: (String) -> Unit, onToggleAlbumLike: () -> Unit, onToggleTrackLike: (String) -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -134,6 +154,8 @@ private fun AlbumBody( liked = track.id in likedTrackIds, onClick = { onTrackClick(track.id) }, onToggleLike = { onToggleTrackLike(track.id) }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, ) HorizontalDivider() } @@ -250,6 +272,8 @@ private fun TrackRow( liked: Boolean, onClick: () -> Unit, onToggleLike: () -> Unit, + onNavigateToAlbum: (String) -> Unit, + onNavigateToArtist: (String) -> Unit, ) { Row( modifier = Modifier @@ -289,6 +313,11 @@ private fun TrackRow( modifier = Modifier.padding(start = 12.dp), ) LikeButton(liked = liked, onToggle = onToggleLike) + TrackActionsButton( + track = track, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsButton.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsButton.kt new file mode 100644 index 00000000..b3227e3e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsButton.kt @@ -0,0 +1,49 @@ +package com.fabledsword.minstrel.shared.widgets.track_actions + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.composables.icons.lucide.EllipsisVertical +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.models.TrackRef + +/** + * Small kebab IconButton that opens [TrackActionsSheet] for [track]. + * Drop into any track row alongside (or in place of) a LikeButton. + * + * [hideQueueActions] suppresses Play next + Add to queue — set true + * on the NowPlaying caller where the menu's track IS the playing one. + * [onNavigateToAlbum] / [onNavigateToArtist] are invoked AFTER the + * sheet has dismissed, so callers that live on a full-screen overlay + * (e.g. NowPlaying) can pop themselves first inside the callback. + */ +@Composable +fun TrackActionsButton( + track: TrackRef, + hideQueueActions: Boolean = false, + onNavigateToAlbum: (albumId: String) -> Unit, + onNavigateToArtist: (artistId: String) -> Unit, +) { + var show by remember { mutableStateOf(false) } + IconButton(onClick = { show = true }) { + Icon( + imageVector = Lucide.EllipsisVertical, + contentDescription = "Track actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (show) { + TrackActionsSheet( + track = track, + hideQueueActions = hideQueueActions, + onDismiss = { show = false }, + onNavigateToAlbum = onNavigateToAlbum, + onNavigateToArtist = onNavigateToArtist, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsSheet.kt new file mode 100644 index 00000000..25b197fe --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsSheet.kt @@ -0,0 +1,164 @@ +package com.fabledsword.minstrel.shared.widgets.track_actions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.Disc3 +import com.composables.icons.lucide.Eye +import com.composables.icons.lucide.EyeOff +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.ListPlus +import com.composables.icons.lucide.ListVideo +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Radio +import com.composables.icons.lucide.User +import com.fabledsword.minstrel.models.TrackRef + +/** + * The 7-item track-actions modal bottom sheet. Mirrors Flutter's + * `TrackActionsSheet`. Opens via [TrackActionsButton] (or any caller + * holding a `TrackRef`); closes via [onDismiss] after any item runs + * or when the user swipes down. + * + * The sheet pops first when an item is tapped (immediate visual + * feedback) and then runs the action. For Add to playlist… and Hide, + * the sub-sheet opens on top of this one; the parent stays mounted + * so the user can pop back out without losing context. + * + * [hideQueueActions] suppresses Play next + Add to queue — set true + * on the NowPlaying caller where the menu's track IS the playing one. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TrackActionsSheet( + track: TrackRef, + hideQueueActions: Boolean, + onDismiss: () -> Unit, + onNavigateToAlbum: (albumId: String) -> Unit, + onNavigateToArtist: (artistId: String) -> Unit, + viewModel: TrackActionsViewModel = hiltViewModel(), +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val isLiked by viewModel.isLikedFlow(track.id).collectAsStateWithLifecycle(initialValue = false) + val isHidden by viewModel.isHiddenFlow(track.id).collectAsStateWithLifecycle(initialValue = false) + + var showAddToPlaylist by remember { mutableStateOf(false) } + var showHideSheet by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + if (!hideQueueActions) { + MenuItem(Lucide.ListVideo, "Play next") { + viewModel.playNext(track) + onDismiss() + } + MenuItem(Lucide.ListMusic, "Add to queue") { + viewModel.enqueue(track) + onDismiss() + } + HorizontalDivider() + } + MenuItem(Lucide.Heart, if (isLiked) "Unlike" else "Like") { + viewModel.toggleLike(track.id, isLiked) + onDismiss() + } + MenuItem(Lucide.ListPlus, "Add to playlist…") { + showAddToPlaylist = true + } + MenuItem(Lucide.Radio, "Start radio") { + viewModel.startRadio(track.id) + onDismiss() + } + HorizontalDivider() + MenuItem(Lucide.Disc3, "Go to album") { + onDismiss() + if (track.albumId.isNotEmpty()) onNavigateToAlbum(track.albumId) + } + MenuItem(Lucide.User, "Go to artist") { + onDismiss() + if (track.artistId.isNotEmpty()) onNavigateToArtist(track.artistId) + } + HorizontalDivider() + MenuItem( + icon = if (isHidden) Lucide.Eye else Lucide.EyeOff, + label = if (isHidden) "Unhide" else "Hide", + ) { + if (isHidden) { + viewModel.unflag(track.id) + onDismiss() + } else { + showHideSheet = true + } + } + } + } + + if (showAddToPlaylist) { + AddToPlaylistSheet( + onPick = { playlistId -> + viewModel.appendToPlaylist(playlistId, track.id) + showAddToPlaylist = false + onDismiss() + }, + onDismiss = { showAddToPlaylist = false }, + ) + } + if (showHideSheet) { + HideTrackSheet( + onSubmit = { reason, notes -> + viewModel.flag(track, reason, notes) + showHideSheet = false + onDismiss() + }, + onDismiss = { showHideSheet = false }, + ) + } +} + +@Composable +private fun MenuItem(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsViewModel.kt new file mode 100644 index 00000000..3aab8a57 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/TrackActionsViewModel.kt @@ -0,0 +1,126 @@ +package com.fabledsword.minstrel.shared.widgets.track_actions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.likes.data.LikesRepository +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.quarantine.data.QuarantineRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Backs [TrackActionsSheet] with state observations + action callbacks. + * Scoped to the call-site screen via `hiltViewModel()`. The sheet hands + * a [TrackRef] into the action methods; the VM doesn't carry per-sheet + * trackId state — it exposes the flows + actions the sheet needs. + * + * Snackbar copy emits on [transientMessages] — call sites collect this + * and route to their existing SnackbarHostState. + * + * Hidden-state observation: [QuarantineRepository] doesn't yet expose + * a Flow for the caller's quarantine list (no Room cache; see the + * repository's class comment), so this VM holds its own snapshot of + * the hidden track-id set. Refreshed on init and after every flag / + * unflag action so the sheet reflects the current state next time + * it opens for the same track. + */ +@HiltViewModel +class TrackActionsViewModel @Inject constructor( + private val likes: LikesRepository, + private val quarantine: QuarantineRepository, + private val player: PlayerController, + private val playlists: PlaylistsRepository, +) : ViewModel() { + + private val messages = MutableSharedFlow(extraBufferCapacity = 4) + val transientMessages: SharedFlow = messages.asSharedFlow() + + private val hiddenTrackIdsInternal = MutableStateFlow>(emptySet()) + val hiddenTrackIds: StateFlow> = hiddenTrackIdsInternal.asStateFlow() + + init { + refreshHidden() + } + + fun isLikedFlow(trackId: String): Flow = + likes.observeIsLiked(LikesRepository.ENTITY_TRACK, trackId) + + fun isHiddenFlow(trackId: String): Flow = + hiddenTrackIds.map { trackId in it } + + fun playNext(track: TrackRef) { + player.playNext(track) + messages.tryEmit("Added to queue (next)") + } + + fun enqueue(track: TrackRef) { + player.enqueue(track) + messages.tryEmit("Added to queue") + } + + fun toggleLike(trackId: String, currentlyLiked: Boolean) { + viewModelScope.launch { + runCatching { + likes.toggleLike( + entityType = LikesRepository.ENTITY_TRACK, + entityId = trackId, + desiredState = !currentlyLiked, + ) + } + } + } + + fun startRadio(trackId: String) { + viewModelScope.launch { + runCatching { player.startRadio(trackId) } + .onFailure { messages.tryEmit("Couldn't start radio: ${it.message ?: "unknown error"}") } + } + } + + fun appendToPlaylist(playlistId: String, trackId: String) { + viewModelScope.launch { + runCatching { playlists.appendTrack(playlistId, trackId) } + .onSuccess { messages.tryEmit("Added to playlist") } + .onFailure { messages.tryEmit("Couldn't add to playlist: ${it.message ?: "unknown error"}") } + } + } + + fun flag(track: TrackRef, reason: String, notes: String) { + viewModelScope.launch { + runCatching { quarantine.flag(track.id, reason, notes) } + .onSuccess { refreshHidden() } + .onFailure { messages.tryEmit("Couldn't hide: ${it.message ?: "unknown error"}") } + } + } + + fun unflag(trackId: String) { + viewModelScope.launch { + runCatching { quarantine.unflag(trackId) } + .onSuccess { refreshHidden() } + .onFailure { messages.tryEmit("Couldn't unhide: ${it.message ?: "unknown error"}") } + } + } + + private fun refreshHidden() { + viewModelScope.launch { + runCatching { quarantine.listMine() } + .onSuccess { rows -> + hiddenTrackIdsInternal.value = rows.map { it.trackId }.toSet() + } + // Failure leaves the previous snapshot; the sheet still + // shows "Hide" by default which is safe (server is + // idempotent on re-flag). + } + } +}