address feedback: immediate navigation, now-playing fade, stack focus, sheet polish

- menu actions run before the sheet collapses, so tapping an album/artist navigates
  immediately instead of waiting for the dismiss; the sheet still animates closed
- host the sheet in the app shell so it survives the navigation it triggers
- now playing fades out (rather than sliding) when its menu navigates to an album/artist,
  and navigates first
- navigating to a detail screen already on top of the stack focuses it instead of pushing
  a duplicate
- drop the drag handle from both sheets, even the menu header's left/top margins, and add
  a bottom margin
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent c28271590e
commit 5aea1d6b4e
7 changed files with 163 additions and 99 deletions
+72 -17
View File
@@ -60,6 +60,8 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.AlbumDetailRoute
@@ -93,6 +95,7 @@ class RootViewModel(
private const val NAVIGATION_DURATION_MS = 260 private const val NAVIGATION_DURATION_MS = 260
private const val OVERLAY_DURATION_MS = 200 private const val OVERLAY_DURATION_MS = 200
private const val FADE_OUT_MS = 150
private const val EXPAND_FADE = 0.1f private const val EXPAND_FADE = 0.1f
private const val FLING_VELOCITY = 1000f private const val FLING_VELOCITY = 1000f
private const val MINI_PLAYER_ANIM_MS = 200 private const val MINI_PLAYER_ANIM_MS = 200
@@ -145,11 +148,16 @@ private fun MainNavigation() {
var nowPlayingOpen by rememberSaveable { mutableStateOf(false) } var nowPlayingOpen by rememberSaveable { mutableStateOf(false) }
var nowPlayingProgress by remember { mutableFloatStateOf(0f) } var nowPlayingProgress by remember { mutableFloatStateOf(0f) }
var miniPlayerTopPx by remember { mutableFloatStateOf(0f) } var miniPlayerTopPx by remember { mutableFloatStateOf(0f) }
var nowPlayingFadeOut by remember { mutableStateOf(false) }
var settleJob by remember { mutableStateOf<Job?>(null) } var settleJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val contextMenuHost = remember { ContextMenuHost() }
fun settleNowPlaying(open: Boolean) { fun settleNowPlaying(open: Boolean) {
if (open) nowPlayingOpen = true if (open) {
nowPlayingOpen = true
nowPlayingFadeOut = false
}
settleJob?.cancel() settleJob?.cancel()
settleJob = settleJob =
scope.launch { scope.launch {
@@ -159,6 +167,30 @@ private fun MainNavigation() {
if (!open) nowPlayingOpen = false if (!open) nowPlayingOpen = false
} }
} }
fun fadeNowPlaying() {
nowPlayingFadeOut = true
settleJob?.cancel()
settleJob =
scope.launch {
animate(nowPlayingProgress, 0f, animationSpec = tween(FADE_OUT_MS)) { value, _ ->
nowPlayingProgress = value
}
nowPlayingOpen = false
nowPlayingFadeOut = false
}
}
fun navigateDetail(
pattern: String,
argument: String,
value: String,
route: String,
) {
val top = navController.currentBackStackEntry
if (top?.destination?.route == pattern && top.arguments?.getString(argument) == value) return
navController.navigate(route)
}
LaunchedEffect(Unit) { playbackController.connect() } LaunchedEffect(Unit) { playbackController.connect() }
LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f } LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f }
@@ -216,12 +248,21 @@ private fun MainNavigation() {
) { ) {
composable(Routes.LIBRARY) { composable(Routes.LIBRARY) {
LibraryRoute( LibraryRoute(
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, onAlbumClick = { album ->
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) }, navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, },
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
},
onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) }, onViewArtist = { artistId ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
contextMenuHost = contextMenuHost,
bottomInset = bottomInset, bottomInset = bottomInset,
) )
} }
@@ -250,7 +291,10 @@ private fun MainNavigation() {
.orEmpty() .orEmpty()
.ifEmpty { null }, .ifEmpty { null },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) }, onViewArtist = { artistId ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
contextMenuHost = contextMenuHost,
) )
} }
composable( composable(
@@ -272,8 +316,11 @@ private fun MainNavigation() {
.orEmpty() .orEmpty()
.ifEmpty { null }, .ifEmpty { null },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, onAlbumClick = { album ->
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) }, navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
contextMenuHost = contextMenuHost,
) )
} }
composable( composable(
@@ -283,8 +330,11 @@ private fun MainNavigation() {
PlaylistDetailRoute( PlaylistDetailRoute(
playlistId = entry.arguments?.getString("playlistId").orEmpty(), playlistId = entry.arguments?.getString("playlistId").orEmpty(),
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) }, onViewArtist = { artistId ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
contextMenuHost = contextMenuHost,
) )
} }
composable(Routes.ADD_SERVER) { composable(Routes.ADD_SERVER) {
@@ -330,8 +380,8 @@ private fun MainNavigation() {
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
translationY = (1f - nowPlayingProgress) * miniPlayerTopPx translationY = if (nowPlayingFadeOut) 0f else (1f - nowPlayingProgress) * miniPlayerTopPx
alpha = (nowPlayingProgress / EXPAND_FADE).coerceAtMost(1f) alpha = if (nowPlayingFadeOut) nowPlayingProgress else (nowPlayingProgress / EXPAND_FADE).coerceAtMost(1f)
}.draggable( }.draggable(
orientation = Orientation.Vertical, orientation = Orientation.Vertical,
state = state =
@@ -348,13 +398,14 @@ private fun MainNavigation() {
onBack = { settleNowPlaying(false) }, onBack = { settleNowPlaying(false) },
onQueue = { showingQueue = true }, onQueue = { showingQueue = true },
onViewAlbum = { albumId -> onViewAlbum = { albumId ->
settleNowPlaying(false) navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId))
navController.navigate(Routes.album(albumId)) fadeNowPlaying()
}, },
onViewArtist = { artistId -> onViewArtist = { artistId ->
settleNowPlaying(false) navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
navController.navigate(Routes.artist(artistId)) fadeNowPlaying()
}, },
contextMenuHost = contextMenuHost,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -389,6 +440,10 @@ private fun MainNavigation() {
) )
} }
} }
contextMenuHost.target?.let { target ->
ItemContextMenu(target = target, actions = contextMenuHost.actions, onDismiss = { contextMenuHost.dismiss() })
}
} }
} }
@@ -1,7 +1,14 @@
package com.subtracks.ui.components package com.subtracks.ui.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -25,6 +32,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -92,6 +100,25 @@ class ItemActions(
val viewArtist: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null,
) )
class ContextMenuHost {
var target by mutableStateOf<MenuTarget?>(null)
private set
var actions by mutableStateOf(ItemActions())
private set
fun show(
target: MenuTarget,
actions: ItemActions,
) {
this.target = target
this.actions = actions
}
fun dismiss() {
target = null
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ItemContextMenu( fun ItemContextMenu(
@@ -103,9 +130,9 @@ fun ItemContextMenu(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
fun dismiss(after: () -> Unit = {}) { fun dismiss(after: () -> Unit = {}) {
after()
scope.launch { scope.launch {
sheetState.hide() sheetState.hide()
after()
onDismiss() onDismiss()
} }
} }
@@ -113,6 +140,7 @@ fun ItemContextMenu(
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { dismiss() }, onDismissRequest = { dismiss() },
sheetState = sheetState, sheetState = sheetState,
dragHandle = null,
) { ) {
MenuHeader(target) MenuHeader(target)
when (target) { when (target) {
@@ -159,39 +187,40 @@ fun ItemContextMenu(
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } }
} }
} }
Spacer(Modifier.height(16.dp))
} }
} }
@Composable @Composable
private fun MenuHeader(target: MenuTarget) { private fun MenuHeader(target: MenuTarget) {
ListItem( Row(
headlineContent = { modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CoverArt(
ref = target.coverArt,
name = target.title,
modifier = Modifier.size(48.dp).clip(if (target is MenuTarget.Artist) CircleShape else RoundedCornerShape(8.dp)),
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text( Text(
text = target.title, text = target.title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
},
supportingContent =
target.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle -> target.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
{ Text(
Text( text = subtitle,
text = subtitle, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow = TextOverflow.Ellipsis, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
} )
}, }
leadingContent = { }
CoverArt( }
ref = target.coverArt,
name = target.title,
modifier = Modifier.size(48.dp).clip(if (target is MenuTarget.Artist) CircleShape else RoundedCornerShape(8.dp)),
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
} }
@Composable @Composable
@@ -13,9 +13,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -28,10 +26,10 @@ import com.subtracks.data.model.Disc
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
@@ -48,6 +46,7 @@ fun AlbumDetailRoute(
coverArtId: String?, coverArtId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
@@ -57,7 +56,6 @@ fun AlbumDetailRoute(
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val actions = val actions =
ItemActions( ItemActions(
playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) }, playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) },
@@ -74,15 +72,12 @@ fun AlbumDetailRoute(
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { menuTarget = it }, onSongLongClick = { contextMenuHost?.show(it, actions) },
onShuffle = viewModel::shuffle, onShuffle = viewModel::shuffle,
onPlay = viewModel::playAll, onPlay = viewModel::playAll,
onMore = { album?.let { menuTarget = MenuTarget.Album(it, viewModel.coverArt(it.coverArt, true)) } }, onMore = { album?.let { contextMenuHost?.show(MenuTarget.Album(it, viewModel.coverArt(it.coverArt, true)), actions) } },
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
) )
menuTarget?.let { target ->
ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null })
}
} }
@Composable @Composable
@@ -61,9 +61,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
@@ -91,6 +91,7 @@ fun ArtistDetailRoute(
onBack: () -> Unit, onBack: () -> Unit,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
) { ) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null) val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
@@ -98,7 +99,6 @@ fun ArtistDetailRoute(
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val actions = val actions =
ItemActions( ItemActions(
playAlbum = { viewModel.playAlbum(it.id) }, playAlbum = { viewModel.playAlbum(it.id) },
@@ -115,12 +115,9 @@ fun ArtistDetailRoute(
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onAlbumLongClick = { menuTarget = it }, onAlbumLongClick = { contextMenuHost?.show(it, actions) },
onMore = { artist?.let { menuTarget = MenuTarget.Artist(it, artThumbnail ?: art) } }, onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art), actions) } },
) )
menuTarget?.let { target ->
ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null })
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -114,8 +114,8 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.prefs.StarredFilter
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
@@ -194,13 +194,13 @@ fun LibraryRoute(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
bottomInset: Dp, bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(), viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val syncing by viewModel.syncing.collectAsStateWithLifecycle() val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle() val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
val listTab = selectedTab.listTab() val listTab = selectedTab.listTab()
@@ -220,6 +220,17 @@ fun LibraryRoute(
} }
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true)) val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
val itemActions =
ItemActions(
playSong = { viewModel.playSong(it.id) },
playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
setStar = viewModel::setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
LibraryScreen( LibraryScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { selectedTab = it },
@@ -237,7 +248,7 @@ fun LibraryRoute(
onSongClick = viewModel::playSong, onSongClick = viewModel::playSong,
onSync = viewModel::sync, onSync = viewModel::sync,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onItemLongClick = { menuTarget = it }, onItemLongClick = { contextMenuHost?.show(it, itemActions) },
playingSongId = playingSongId, playingSongId = playingSongId,
listQuery = listQuery, listQuery = listQuery,
resetKeys = resetKeys, resetKeys = resetKeys,
@@ -261,23 +272,6 @@ fun LibraryRoute(
search = search, search = search,
onSearchChange = { viewModel.setSearch(listTab, it) }, onSearchChange = { viewModel.setSearch(listTab, it) },
) )
menuTarget?.let { target ->
ItemContextMenu(
target = target,
actions =
ItemActions(
playSong = { viewModel.playSong(it.id) },
playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
setStar = viewModel::setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
),
onDismiss = { menuTarget = null },
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -531,7 +525,10 @@ fun LibraryScreen(
} }
if (showOptions) { if (showOptions) {
ModalBottomSheet(onDismissRequest = { showOptions = false }) { ModalBottomSheet(
onDismissRequest = { showOptions = false },
dragHandle = null,
) {
ListOptionsSheet( ListOptionsSheet(
listQuery = listQuery, listQuery = listQuery,
sortOptions = sortOptions, sortOptions = sortOptions,
@@ -16,10 +16,10 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
@@ -36,13 +36,13 @@ fun PlaylistDetailRoute(
onBack: () -> Unit, onBack: () -> Unit,
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val actions = val actions =
ItemActions( ItemActions(
playPlaylist = { viewModel.playAll() }, playPlaylist = { viewModel.playAll() },
@@ -58,15 +58,12 @@ fun PlaylistDetailRoute(
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { menuTarget = it }, onSongLongClick = { contextMenuHost?.show(it, actions) },
onShuffle = viewModel::shuffle, onShuffle = viewModel::shuffle,
onPlay = viewModel::playAll, onPlay = viewModel::playAll,
onMore = { playlist?.let { menuTarget = MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)) } }, onMore = { playlist?.let { contextMenuHost?.show(MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)), actions) } },
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
) )
menuTarget?.let { target ->
ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null })
}
} }
@Composable @Composable
@@ -69,9 +69,9 @@ import com.subtracks.data.repo.LibraryRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.RepeatMode import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkSeedCache import com.subtracks.ui.theme.ArtworkSeedCache
@@ -87,6 +87,7 @@ fun NowPlayingRoute(
onQueue: () -> Unit, onQueue: () -> Unit,
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controller: PlaybackController = koinInject(), controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(), libraryRepository: LibraryRepository = koinInject(),
@@ -101,9 +102,14 @@ fun NowPlayingRoute(
libraryRepository.song(sourceId, songId).collect { value = it } libraryRepository.song(sourceId, songId).collect { value = it }
} }
} }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val scope = rememberCoroutineScope()
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val scope = rememberCoroutineScope()
val actions =
ItemActions(
setStar = { type, id, starred -> scope.launch { libraryRepository.setStar(type, id, starred) } },
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
val art = controller.coverArt(state.item) val art = controller.coverArt(state.item)
val thumbnail = controller.coverArt(state.item, thumbnail = true) val thumbnail = controller.coverArt(state.item, thumbnail = true)
val queueContext = state.context val queueContext = state.context
@@ -138,22 +144,10 @@ fun NowPlayingRoute(
onPrevious = controller::previous, onPrevious = controller::previous,
onShuffle = controller::toggleShuffle, onShuffle = controller::toggleShuffle,
onRepeat = controller::cycleRepeat, onRepeat = controller::cycleRepeat,
onMore = { song?.let { menuTarget = MenuTarget.Song(it, thumbnail) } }, onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail), actions) } },
onSeek = controller::seekTo, onSeek = controller::seekTo,
modifier = modifier, modifier = modifier,
) )
menuTarget?.let { target ->
ItemContextMenu(
target = target,
actions =
ItemActions(
setStar = { type, id, starred -> scope.launch { libraryRepository.setStar(type, id, starred) } },
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
),
onDismiss = { menuTarget = null },
)
}
} }
internal fun prefetchImage( internal fun prefetchImage(